State
En resumen: State convierte cada estado de un objeto (como un pedido) en un objeto propio que sabe qué acciones permite y qué estado viene después, en lugar de un if sobre el estado en cada método.
Problema
El pedido que comprueba su estado en todas partes
Un pedido de la tienda pasa por nuevo → pagado → enviado → entregado, y también se puede cancelar. El estado es texto, y cada método lo comprueba:
class Order: def __init__(self): self.status = "nuevo"
def pay(self): if self.status == "nuevo": self.status = "pagado" else: raise RuntimeError(f"Pedido {self.status}: no se puede pagar")
def ship(self): if self.status == "cancelado": raise RuntimeError(f"Pedido {self.status}: no se puede enviar") self.status = "enviado"
def deliver(self): if self.status == "enviado": self.status = "entregado" else: raise RuntimeError(f"Pedido {self.status}: no se puede entregar")
def cancel(self): if self.status == "entregado": raise RuntimeError(f"Pedido {self.status}: no se puede cancelar") if self.status != "nuevo": print("Devolvemos el dinero al cliente") self.status = "cancelado"Dos pedidos:
first = Order()first.ship()print("Primero: " + first.status)
second = Order()second.pay()second.ship()second.cancel()print("Segundo: " + second.status)Primero: enviadoDevolvemos el dinero al clienteSegundo: canceladoEl primero se envió sin estar pagado: ship solo comprueba «cancelado». El segundo se canceló mientras el paquete va de camino. El cliente recupera el dinero, y el mensajero lo entregará de todos modos.
Nadie escribió un error a propósito. Cuatro eventos por cinco estados son veinte combinaciones, y cada método recuerda solo algunas. Un estado nuevo «empacado» obliga a pensar otra vez en los cuatro métodos, y la combinación olvidada se colará igualmente sin hacer ruido.
La idea
State: cada estado sabe qué permite
La pregunta «¿está permitido este evento ahora?» tiene una respuesta que depende del estado. Así que la respuesta pasa al propio estado.
Cada estado es un objeto de su propia clase. Dice qué eventos permite y qué estado viene después. La clase base común OrderState rechaza todos los eventos con un error claro, y cada estado sobrescribe solo los permitidos. El pedido no comprueba nada: pasa el evento al estado actual y recuerda lo que este devuelve.
Este es el patrón State. Tiene tres papeles:
- el pedido guarda el estado actual. Los libros lo llaman el contexto;
- la clase base
OrderState: todos los eventos, y cada uno rechaza por defecto; - los estados concretos:
NewState,PaidState,ShippedState,DeliveredState,CancelledState.
Míralo
Los cinco estados y el pedido
La clase base: cada evento rechaza con un texto que incluye el nombre del estado.
class OrderState: name = ""
def pay(self): raise self.refuse("pagar")
def ship(self): raise self.refuse("enviar")
def deliver(self): raise self.refuse("entregar")
def cancel(self): raise self.refuse("cancelar")
def refuse(self, action): return RuntimeError(f"Pedido {self.name}: no se puede {action}")Cada estado sobrescribe solo lo que permite. Cancelar un pedido pagado devuelve el dinero, mientras que cancelar uno nuevo no devuelve nada, porque no se ha pagado nada. Este comportamiento está en su propio estado, sin un if:
class NewState(OrderState): name = "nuevo"
def pay(self): return PaidState()
def cancel(self): return CancelledState()
class PaidState(OrderState): name = "pagado"
def ship(self): return ShippedState()
def cancel(self): print("Devolvemos el dinero al cliente") return CancelledState()
class ShippedState(OrderState): name = "enviado"
def deliver(self): return DeliveredState()
class DeliveredState(OrderState): name = "entregado"
class CancelledState(OrderState): name = "cancelado"El pedido guarda el estado actual y le pasa cada evento:
class Order: def __init__(self): self.state = NewState()
@property def status(self): return self.state.name
def pay(self): self.state = self.state.pay()
def ship(self): self.state = self.state.ship()
def deliver(self): self.state = self.state.deliver()
def cancel(self): self.state = self.state.cancel()Las dos combinaciones del problema, y luego todo el recorrido:
order = Order()try: order.ship()except RuntimeError as e: print(e)order.pay()order.ship()print(order.status)try: order.cancel()except RuntimeError as e: print(e)order.deliver()print(order.status)Pedido nuevo: no se puede enviarenviadoPedido enviado: no se puede cancelarentregadoorder = Order()try: order.ship()except RuntimeError as e: print(e)order.pay()order.ship()print(order.status)try: order.cancel()except RuntimeError as e: print(e)order.deliver()print(order.status)Paso a paso, en palabras
- Un pedido nuevo: su campo state apunta a NewState («nuevo»).
- ship() en un pedido nuevo. NewState no tiene una flecha ship, así que rechaza la clase base y catch imprime el error.
- pay(): NewState lo permite y devuelve PaidState. El pedido solo recuerda el estado nuevo.
- El mismo ship() de antes, pero ahora responde PaidState: lo permite, y el siguiente estado es ShippedState.
- El estado es el nombre del estado actual: enviado.
- cancel(), mientras el paquete va de camino. ShippedState no tiene una flecha cancel: un muro, y no se devuelve el dinero.
- deliver(): de «enviado» a «entregado».
- Nadie escribió una comprobación para las dos combinaciones prohibidas. No tienen flecha, así que se rechazan.
Desde «nuevo» no hay una flecha ship, así que enviar se rechaza. Desde «enviado» no hay una flecha cancel. Nadie escribió una comprobación para estas dos combinaciones: están prohibidas porque ningún estado las permitió.
Míralo
Un evento nuevo sin tocar los otros estados
El almacén quiere el paquete empacado antes de que salga: pagado → empacado → enviado. El evento nuevo va a la clase base, y rechaza como los demás:
def pack(self): raise self.refuse("empacar")PaidState ahora permite pack en lugar de ship, y el nuevo PackedState permite ship y cancelar con devolución:
class PaidState(OrderState): name = "pagado"
def pack(self): return PackedState()
def cancel(self): print("Devolvemos el dinero al cliente") return CancelledState()
class PackedState(OrderState): name = "empacado"
def ship(self): return ShippedState()
def cancel(self): print("Devolvemos el dinero al cliente") return CancelledState()El pedido recibe el método pack, que solo pasa el evento:
def pack(self): self.state = self.state.pack()order = Order()try: order.pack()except RuntimeError as e: print(e)order.pay()order.pack()order.ship()print(order.status)Pedido nuevo: no se puede empacarenviadoNewState, ShippedState, DeliveredState y CancelledState no se tocaron, y empacar está prohibido en ellos sin que escribas una línea para eso. Un pedido pagado ya no se puede enviar directamente, porque PaidState no tiene ship. Con la cadena de if, el evento nuevo tenía que ocurrírsete en cada método.
Míralo
Sin una clase por estado: un enum y una tabla
Aquí, cancelar un pedido pagado devuelve el dinero: el estado hace algo, y eso pertenece a una clase. Si los estados no hacen nada y solo dicen qué transición está permitida, basta con menos.
UnEnum (del módulo enum) es un tipo con una lista fija de valores, y auto() los numera por ti. El estado es uno de ellos, y el diccionario dice a qué estados puedes pasar desde cada uno:class Status(Enum): NEW = auto() PAID = auto() SHIPPED = auto() DELIVERED = auto() CANCELLED = auto()
NEXT = { Status.NEW: {Status.PAID, Status.CANCELLED}, Status.PAID: {Status.SHIPPED, Status.CANCELLED}, Status.SHIPPED: {Status.DELIVERED}, Status.DELIVERED: set(), Status.CANCELLED: set(),}
class Order: def __init__(self): self.status = Status.NEW
def move_to(self, next_status): if next_status not in NEXT[self.status]: raise RuntimeError(f"{self.status.name} → {next_status.name}: no permitido") self.status = next_statusorder = Order()order.move_to(Status.PAID)order.move_to(Status.SHIPPED)try: order.move_to(Status.CANCELLED)except RuntimeError as e: print(e)print(order.status.name)SHIPPED → CANCELLED: no permitidoSHIPPEDLa comprobación está en un solo lugar, y las transiciones son datos: una fila de la tabla para un estado nuevo. Es la elección honesta cuando los estados no tienen comportamiento. En cuanto alguna transición tiene que hacer algo (devolver el dinero, enviar un correo, anotar cuándo se envió), a la tabla le empiezan a crecer if alrededor, y entonces State compensa con sus clases.
Más precisamente, para curiosos
Los estados de aquí no tienen campos, así que bastaría un objeto de cada uno para todos los pedidos. Entonces cada estado puede ser un valor ya hecho en el módulo en lugar de un objeto nuevo en cada transición. Esto ahorra objetos, pero tiene un coste: en cuanto un estado recibe un campo (el importe a devolver, la fecha de pago), un objeto compartido lo comparte entre todos los pedidos. Por eso new en cada transición es el comienzo más seguro.
Pruébalo
Predice la salida
El pedido se paga y se cancela enseguida, y luego alguien intenta enviarlo. ¿Qué imprime el programa?
order = Order()order.pay()order.cancel()print(order.status)try: order.ship()except RuntimeError as e: print(e)Pruébalo
Encuentra el error
Casi todos los estados permiten cancelar, así que alguien decide escribirlo una sola vez, en la clase base:
class OrderState: name = ""
def pay(self): raise self.refuse("pagar")
def ship(self): raise self.refuse("enviar")
def deliver(self): raise self.refuse("entregar")
def cancel(self): return CancelledState()
def refuse(self, action): return RuntimeError(f"Pedido {self.name}: no se puede {action}")order = Order()order.pay()order.ship()order.cancel()print(order.status)canceladoEl pedido enviado se canceló sin error, y el paquete va de camino. ¿En qué línea está el error?
Elige la línea con el error.
ShippedState y DeliveredState no tienen su propio cancel, así que heredan el de la clase base, y ese ahora lo permite. El valor por defecto de la clase base debe rechazar. Entonces un evento olvidado se detiene con un error en lugar de colarse sin hacer ruido. Lo permitido se escribe de forma explícita, en el estado que lo permite, como lo hacen NewState y PaidState. Devuelve el rechazo a esta línea, y el pedido enviado vuelve a decir «Pedido enviado: no se puede cancelar».
La idea
Cuándo no lo necesitas
State tiene un coste: una clase por estado y una clase base, en lugar de un campo. No compensa cuando:
- hay dos estados y una transición. «Pagado o no» es un campo booleano;
- los estados no tienen comportamiento, solo transiciones permitidas. Entonces bastan un enum y la tabla de la tarjeta anterior;
- las transiciones vienen de fuera (el dueño de la tienda las configura en el panel de administración). Las clases viven en el código, mientras que los datos pueden cambiar sin una versión nueva del programa.
Compensa cuando hay más estados, cada uno hace algo propio ante un evento y siguen llegando nuevos.
Ejercicio
Devoluciones en 14 días
Resuelve la tarea en tu editor.
Devoluciones. El cliente puede devolver un pedido entregado, y la tienda le reembolsa el dinero. Añade un evento return_order y un estado «devuelto». Solo está permitido desde «entregado». Lleva un pedido hasta entregado y devuélvelo, y luego intenta devolver un pedido nuevo.
Devolvemos el dinero al clientedevueltoPedido nuevo: no se puede devolverMás tareas
- Historial. El pedido recuerda los estados por los que pasó y los imprime así:
nuevo → pagado → enviado → entregado. ¿Dónde anotas el siguiente estado: en el pedido o en cada estado? - Un correo en cada cambio. Tras cada transición permitida el cliente recibe una línea «Correo: el pedido está pagado». Haz que un evento rechazado no envíe ningún correo.
- Empacado con la tabla. Añade «empacado» a la versión con el enum y la tabla. Cuenta cuántos lugares cambiaste allí y cuántos en la versión con clases.
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Excepciones · Lanzar excepciones y crear las tuyas · dict y set · Patrones de diseño
- Continúa en: La tienda: qué patrón dónde · Code smells: método largo, clase grande, duplicación, primitivos en vez de tipos, cadenas largas de if · Refactorización en pasos pequeños
- Ejercicios sobre el tema: Patrones de diseño