Ir al contenido
wannacode

Patrones de diseño

En resumen: Un patrón de diseño es una solución probada para un problema que aparece en muchos programas, por ejemplo Strategy, Observer, Factory Method, Singleton y Adapter.

Problema

La función que crece con cada promoción

El precio en la tienda depende de la promoción:

def final_price(price, promo):
if promo == "estudiante":
return price * 0.9
elif promo == "black friday":
return max(price - 20, 0)
elif promo == "cliente fiel":
return price * 0.95
return price
print(f"{final_price(120, 'estudiante'):.2f} €")
print(f"{final_price(120, 'black friday'):.2f} €")
print(f"{final_price(120, 'ninguno'):.2f} €")
Salida
108.00 €
100.00 €
120.00 €
Funciona. Pero marketing inventa una promoción nueva cada mes. Cada una es un nuevo elif en esta función, y a menudo en dos más: la de la factura y la del correo de confirmación. La función conoce todas las promociones a la vez, y cada cambio la toca. Este problema es tan común que su solución tiene nombre.

La idea

Patrones de diseño

Un patrón de diseño es una solución probada para un problema que aparece en muchos programas. No es una biblioteca ni código para copiar. Es una forma de organizar las clases y las funciones que ya pasó por miles de proyectos.

Los patrones se dividen en tres grupos:

  • Creacionales: cómo se crean los objetos. Por ejemplo Singleton (un solo objeto de la clase) y Factory Method (otra cosa decide qué clase crear).
  • Estructurales: cómo se juntan clases y objetos en otros más grandes. Por ejemplo Adapter (conecta interfaces incompatibles) y Composite (un árbol de partes con el que trabajas como con un todo).
  • De comportamiento: cómo se reparten el trabajo los objetos y cómo se hablan. Por ejemplo Strategy (una regla reemplazable) y Observer (suscriptores a un evento).

Por qué vale la pena conocerlos:

  • no reinventas una solución que otros ya probaron;
  • el código cambia con menos riesgo, porque la parte que cambia está separada;
  • los nombres son un idioma común. «Aquí pon un adaptador» le dice a un colega toda una estructura en una palabra.
Más precisamente, para curiosos
El catálogo clásico es el libro «Design Patterns» de 1994, de cuatro autores conocidos como la «Banda de los Cuatro» (Gang of Four, GoF). Tiene 23 patrones, escritos para lenguajes como C++ y Java. Aquí están los cinco con los que más te vas a encontrar. En Python algunos se acortan, porque una función se puede pasar como valor: donde Java necesita una interfaz y una clase, aquí basta una función. Un patrón no es un objetivo: si un if hace el trabajo, no necesitas un patrón.

Míralo

Strategy: una regla reemplazable

El descuento pasa a ser una función que la caja guarda en un campo. La caja no sabe qué promoción es, solo llama a la función:
class Checkout:
def __init__(self, discount):
self.discount = discount
def total(self, price):
return max(self.discount(price), 0)
Cada descuento es una lambda:
checkout = Checkout(lambda price: price)
print(f"Sin descuento: {checkout.total(120):.2f} €")
checkout.discount = lambda price: price * 0.9
print(f"Estudiante: {checkout.total(120):.2f} €")
checkout.discount = lambda price: price - 20
print(f"Black Friday: {checkout.total(120):.2f} €")
Salida
Sin descuento: 120.00 €
Estudiante: 108.00 €
Black Friday: 100.00 €
Una promoción nueva es una lambda nueva. Checkout no cambia. La regla también se puede cambiar mientras el programa se ejecuta: guardas una función nueva en checkout.discount.Esto es Strategy: una familia de reglas, cada una con los mismos parámetros y el mismo resultado, así que son intercambiables. El key que pasas a sorted también es una estrategia: sorted sabe cómo ordenar, y tú dices por qué ordenar.
Más precisamente, para curiosos

En los libros, la clase que usa la estrategia se llama «contexto» (aquí Checkout). Si la estrategia tiene sus propios campos, puede ser una clase. Checkout llama a self.discount(price), así que el objeto tiene que poder llamarse como una función: eso se lo da el método __call__:

class PercentDiscount:
def __init__(self, percent):
self.percent = percent
def __call__(self, price):
return price * (1 - self.percent / 100)
checkout = Checkout(PercentDiscount(10))
print(f"Estudiante: {checkout.total(120):.2f} €")
Salida
Estudiante: 108.00 €

Una clase es mejor opción cuando la estrategia tiene sus propios campos o es larga. Una lambda, cuando es una línea.

Míralo

Observer: suscriptores a un evento

Cuando cambia el stock de un producto, el sitio web tiene que mostrar la nueva cantidad, y el almacén tiene que reponer si quedan pocos. Mañana puede ser también un correo a los clientes. El producto no debería saber de ninguno de ellos.

class Product:
def __init__(self, name, quantity):
self.name = name
self.quantity = quantity
self.listeners = []
def subscribe(self, listener):
self.listeners.append(listener)
def set_quantity(self, quantity):
if quantity == self.quantity:
return
self.quantity = quantity
for listener in self.listeners:
listener(self.name, quantity)
phone = Product("Teléfono", 0)
phone.subscribe(lambda name, qty: print(f"Sitio web: {name}, {qty} en stock"))
def store(name, qty):
if qty < 3:
print(f"Almacén: reponer {name}")
phone.subscribe(store)
phone.set_quantity(10)
phone.set_quantity(2)
Salida
Sitio web: Teléfono, 10 en stock
Sitio web: Teléfono, 2 en stock
Almacén: reponer Teléfono
  • Quien quiere enterarse se suscribe con subscribe y pasa una función.
  • Cuando hay un cambio, el producto recorre la lista y llama a cada función con el nombre y la cantidad. Quiénes son los suscriptores y qué hacen no es asunto suyo.
  • Si la cantidad no cambió, no se molesta a nadie.
  • Una lambda en Python es solo una expresión, e if no es una expresión. Por eso el almacén es una función normal con def, y subscribe(store) pasa la propia función, sin paréntesis.

phone = Product("Teléfono", 0)
phone.subscribe(lambda name, qty: print(f"Sitio web: {name}, {qty} en stock"))
def store(name, qty):
if qty < 3:
print(f"Almacén: reponer {name}")
phone.subscribe(store)
phone.set_quantity(10)
phone.set_quantity(2)
Paso a paso, en palabras
  1. Teléfono con 0 en stock y una lista listeners vacía.
  2. El sitio web se suscribe: la lambda entra en la lista.
  3. Lo mismo hace el almacén, con la función store. El producto no sabe quiénes son, solo que puede llamarlos con un nombre y una cantidad.
  4. El stock pasa a ser 10. El producto recorre la lista y llama a cada suscriptor en el orden en que se suscribió. El sitio web muestra la nueva cantidad.
  5. El almacén comprueba 10 < 3: no, así que no hace nada.
  6. El stock pasa a ser 2. El producto recorre la lista y llama a cada suscriptor en el orden en que se suscribió. El sitio web muestra la nueva cantidad.
  7. El almacén comprueba 2 < 3: sí, así que repone.

Esto es Observer. El producto es el objeto observado (el sujeto), y los oyentes son los observadores. Los botones de los programas gráficos funcionan exactamente así: «cuando lo pulsen, llama a esto».

Más precisamente, para curiosos
  • También hace falta un unsubscribe que quite al oyente de la lista. Quitas la misma función que suscribiste, así que la guardas en una variable. Un suscriptor que nadie quita se queda en memoria mientras exista el producto.
phone = Product("Teléfono", 5)
site = lambda name, qty: print(f"Sitio web: {qty} en stock")
phone.subscribe(site)
phone.set_quantity(4)
phone.listeners.remove(site)
phone.set_quantity(3)
print("El sitio web ya no escucha")
Salida
Sitio web: 4 en stock
El sitio web ya no escucha
  • En la lección antigua, la clase observada se llama Subject, con los métodos attach y notifyAllObservers, y el observador es una clase abstracta con un método update(). La idea es la misma, y con funciones no necesitas una clase para cada observador.
  • Los oyentes se llaman uno tras otro, dentro del método que cambió la cantidad. Si uno es lento, todos esperan.

Pruébalo

¿Quién lo oirá?

Product es el de la tarjeta anterior.

¿Qué imprimirá el programa?

coffee = Product("Café", 5)
coffee.subscribe(lambda name, qty: print(f"A: {qty}"))
coffee.set_quantity(4)
coffee.set_quantity(4)
coffee.subscribe(lambda name, qty: print(f"B: {qty}"))
coffee.set_quantity(0)
Respuesta

Míralo

Factory Method: qué clase crear

El cliente eligió cómo lo avisamos: por correo o por SMS. El código que envía no debería escribir EmailNotification() en diez lugares, cada uno con su propio if. El contrato de una «notificación» es una clase abstracta, como en el módulo «Herencia y polimorfismo»:
from abc import ABC, abstractmethod
class Notification(ABC):
@abstractmethod
def send(self, to, text):
pass
class EmailNotification(Notification):
def send(self, to, text):
print(f"Correo a {to}: {text}")
class SmsNotification(Notification):
def send(self, to, text):
print(f"SMS a {to}: {text}")
La elección está en un solo lugar, en una fábrica. Es una función normal: en Java es un método estático en una clase solo porque allí todo está en una clase.
def create_notification(channel):
match channel:
case "email":
return EmailNotification()
case "sms":
return SmsNotification()
case _:
raise ValueError(f"Canal desconocido: {channel}")
customers = [
("email", "maria@example.com"),
("sms", "+34 612 345 678"),
("paloma", "Calle Mayor 5"),
]
for channel, to in customers:
try:
n = create_notification(channel)
n.send(to, "Tu pedido ha sido enviado.")
except ValueError as e:
print(e)
Salida
Correo a maria@example.com: Tu pedido ha sido enviado.
SMS a +34 612 345 678: Tu pedido ha sido enviado.
Canal desconocido: paloma
Quien llama dice qué quiere («email») y recibe una Notification. Solo la fábrica sabe exactamente qué clase hay detrás. Un canal nuevo, por ejemplo push, es una clase nueva y un caso nuevo en el match. El envío no se toca.
Más precisamente, para curiosos
  • Esta es la forma más común en la práctica. Su nombre más preciso es «fábrica simple». El Factory Method del libro GoF es otra cosa: la clase padre tiene un método abstracto que crea el objeto, y las subclases deciden de qué tipo:
class Shop(ABC):
@abstractmethod
def create_notification(self):
pass
def confirm_order(self, to):
self.create_notification().send(to, "Se recibió el pedido.")
class OnlineShop(Shop):
def create_notification(self):
return EmailNotification()
class PhoneShop(Shop):
def create_notification(self):
return SmsNotification()
OnlineShop().confirm_order("maria@example.com")
PhoneShop().confirm_order("+34 612 345 678")
Salida
Correo a maria@example.com: Se recibió el pedido.
SMS a +34 612 345 678: Se recibió el pedido.

confirm_order se escribe una sola vez, en Shop, y no sabe qué notificación envía. Y Shop() no se puede crear: ABC lo detiene con un TypeError, porque create_notification es abstracto.

  • Si la fábrica no llega a un return, devuelve None. Entonces el error llega recién en send, lejos de la causa real:
def create_notification(channel):
if channel == "email":
return EmailNotification()
if channel == "sms":
return SmsNotification()
n = create_notification("paloma")
try:
n.send("Calle Mayor 5", "Tu pedido ha sido enviado.")
except AttributeError as e:
print("AttributeError:", e)
Salida
AttributeError: 'NoneType' object has no attribute 'send'

Es mejor una excepción con un mensaje claro en la fábrica.

Míralo

Singleton: un solo objeto

La configuración de la aplicación, como la moneda, se lee una vez. Si cada parte del programa crea su propio objeto de configuración, se lee muchas veces y las copias pueden dejar de coincidir.

class AppConfig:
_instance = None
def __new__(cls):
if cls._instance is None:
print("Leyendo la configuración...")
cls._instance = super().__new__(cls)
cls._instance.currency = "EUR"
return cls._instance
a = AppConfig()
b = AppConfig()
print(a is b)
print(b.currency)
Salida
Leyendo la configuración...
True
EUR
  • Python no tiene constructor private: cualquiera puede escribir AppConfig(). Por eso la protección está en __new__. Python llama a __new__ para crear el objeto, incluso antes de __init__.
  • El único objeto está en el campo de clase _instance. __new__ lo crea en la primera llamada y después siempre devuelve ese. El guion bajo en el nombre dice «para uso interno».
  • La moneda también se fija en __new__, solo la primera vez. Por qué no en __init__ está en la tarjeta siguiente.
  • «Leyendo la configuración...» se imprime una vez, y a is b es True: hay un solo objeto.

Esto es Singleton.

Más precisamente, para curiosos
  • Si dos hilos llaman a AppConfig() en el mismo momento, se pueden crear dos objetos. Los hilos están en el módulo «Programas en el mundo real».
  • Un Singleton seguro en Python es un Enum con un solo valor (el módulo enum). Un Enum es un tipo con objetos enumerados de antemano. Python crea cada uno exactamente una vez, y Settings("EUR") no crea un objeto nuevo, sino que encuentra el enumerado:
from enum import Enum
class Settings(Enum):
INSTANCE = "EUR"
def currency(self):
return self.value
print(Settings.INSTANCE.currency())
print(Settings("EUR") is Settings.INSTANCE)
try:
Settings("USD")
except ValueError as e:
print("ValueError:", e)
Salida
EUR
True
ValueError: 'USD' is not a valid Settings
  • En Python es más común otra cosa: el objeto se crea una vez, en el nivel superior de un módulo, y las demás partes lo traen con import.
  • Un Singleton es una variable global para todo el programa con un nombre bonito. Cualquiera puede usarlo desde cualquier lugar, así que es difícil de probar y difícil de reemplazar. A menudo es mejor crear el objeto una vez al inicio del programa y pasarlo como parámetro a quienes lo usan.

Pruébalo

Encuentra el error

Los números de pedido tienen que ser únicos, así que el contador es un Singleton. Pero el programa dio dos veces el número 1001:

numbers = OrderNumbers()
print(numbers.next_number())
print(numbers.next_number())
print(OrderNumbers().next_number())
Salida
1001
1002
1001

¿En qué línea de la clase está el error?

Elige la línea con el error.

Míralo

Adapter: conecta interfaces incompatibles

La tienda paga a través de una interfaz con un importe en euros y una respuesta «sí o no». El banco te da una biblioteca antigua que quiere céntimos y devuelve un código de error. La biblioteca no es tuya, y no puedes cambiarla.

# La biblioteca antigua del banco. No puedes cambiarla.
class LegacyBankClient:
# El importe va en céntimos. Devuelve 0 si todo sale bien y un código de error si no.
def charge(self, cents, reference):
print(f"Banco: {cents} céntimos por {reference}")
return 0 if cents <= 50000 else 51

Escribes una clase que se parece a lo que espera la tienda, y por dentro llama al banco:

from abc import ABC, abstractmethod
# Esto es lo que espera la tienda.
class PaymentGateway(ABC):
@abstractmethod
def pay(self, order_id, amount_eur):
pass
class BankAdapter(PaymentGateway):
def __init__(self, bank):
self.bank = bank
def pay(self, order_id, amount_eur):
cents = round(amount_eur * 100)
return self.bank.charge(cents, f"PEDIDO-{order_id}") == 0
gateway = BankAdapter(LegacyBankClient())
print(gateway.pay("1001", 49.90))
print(gateway.pay("1002", 1299.00))
Salida
Banco: 4990 céntimos por PEDIDO-1001
True
Banco: 129900 céntimos por PEDIDO-1002
False
El adaptador traduce en los dos sentidos: euros a céntimos y un número a texto a la ida, el código 0 a true a la vuelta. La tienda no sabe que detrás hay una biblioteca antigua. Si mañana cambias de banco, escribes un adaptador nuevo, y la tienda no se toca.

Esto es Adapter: como un adaptador de enchufe, que no cambia ni el enchufe ni la pared.

Más precisamente, para curiosos

El ejemplo clásico de los libros, y de la lección antigua, es un reproductor multimedia. AudioPlayer solo sabe reproducir mp3, y una clase existente AdvancedMediaPlayer tiene los métodos playVlc y playMp4. MediaAdapter implementa la interfaz del reproductor y reenvía al método correcto. La estructura es la misma que aquí.

Ejercicio

Patrones en la tienda

Resuelve las tareas en tu editor.

1. Descuentos como estrategias. Tres funciones que devuelven una lambda: none(), percent(p) y fixed(amount) (nunca por debajo de 0 €). Una clase Cart con una lista de precios y un descuento reemplazable en el campo discount. Agrega café en grano (18,50 €), miel (12 €) y té (4,20 €), e imprime el total sin descuento, con −10 %, con −5 € y con −50 €.

Salida
Sin descuento: 34.70 €
-10%: 31.23 €
-5 €: 29.70 €
-50 €: 0.00 €

2. «Avísame cuando haya stock». El producto guarda una lista de clientes que esperan. Cuando el stock pasa de 0 a más de 0, los avisa a todos y vacía la lista, porque el aviso es de una sola vez. La consola tiene 0 en stock, María y Jorge se suscriben, y después el stock pasa a 5, 0 y 3.

Salida
Correo a María: Consola está en stock (5 unidades)
SMS a Jorge: Consola está en stock
Fin

3. Una fábrica de notificaciones. Agrega PushNotification y una fábrica para los tres canales que no distinga mayúsculas de minúsculas. Lee un número de líneas y después líneas «canal;dirección». Envía «Tu reserva está confirmada.» a cada dirección, y para un canal desconocido imprime el mensaje de la excepción.

Entrada
4
Email;maria@example.com
sms;+34 612 345 678
push;el teléfono de Elena
fax;91 123 45 67
Salida
Correo a maria@example.com: Tu reserva está confirmada.
SMS a +34 612 345 678: Tu reserva está confirmada.
Push a el teléfono de Elena: Tu reserva está confirmada.
Canal desconocido: fax
4. Un termómetro antiguo. Una clase OldThermometer devuelve la temperatura en Fahrenheit con read_fahrenheit(). El sistema nuevo trabaja con una clase abstracta TemperatureSensor con un método celsius(). Escribe ThermometerAdapter (°C = (°F − 32) × 5 / 9). En una lista de sensores, pon dos termómetros antiguos (71.6 °F y 39.2 °F) a través del adaptador, y un sensor nuevo DigitalSensor que devuelve 21.5 °C.
Salida
Oficina: 22.0 °C
Almacén: 4.0 °C
Sala de servidores: 21.5 °C

Más tareas

  • El envío como estrategia. Crea funciones para el precio de envío a una oficina, a un domicilio y desde la tienda, y pon la elegida en la caja junto con el descuento.
  • Un registro como Singleton. Una clase Logger con un __new__ que guarda el único objeto, y un método log(text) que numera las líneas. Comprueba que los números continúan cuando lo llamas desde dos funciones distintas.
  • Un observador de precios. Agrega al producto suscriptores a los cambios de precio, y envía un aviso solo cuando el precio baje por lo menos un 10 %.

Test

Ponte a prueba

1. ¿Qué es un patrón de diseño?
2. Cada nueva promoción de la tienda agrega un nuevo elif a la función del precio. ¿Qué patrón ayuda?
3. El producto avisa al sitio web, al almacén y al sistema de correo cuando cambia el stock, sin saber quiénes son. ¿Qué patrón es?
4. Un Singleton con un __new__ que devuelve el objeto ya creado tiene también un __init__ que pone self.next = 1001. ¿Qué pasa en el segundo OrderNumbers()?
5. Tienes una clase antigua que devuelve la temperatura en Fahrenheit, y el programa espera un sensor con un método celsius(). ¿Qué haces?

La idea

Lo siguiente: los patrones de cerca

Aquí viste cinco patrones de forma breve, con lambdas. El curso de Ingeniería de software tiene un módulo entero sobre patrones: cada uno empieza con código que duele, tiene una animación de qué objeto llama a cuál y dice cuándo no lo necesitas. También están Builder, Decorator, Composite, Command, State, Template Method, Iterator y Repository.

El módulo está aquí (por ahora, en inglés): Patrones de diseño.

Creado por Ivo Gergov como parte de la campaña de Evo Tech por una educación accesible en programación ·