Ir al contenido
wannacode

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 {
private String status = "nuevo";
public void pay() {
if (status.equals("nuevo")) {
status = "pagado";
} else {
throw new IllegalStateException("Pedido " + status + ": no se puede pagar");
}
}
public void ship() {
if (status.equals("cancelado")) {
throw new IllegalStateException("Pedido " + status + ": no se puede enviar");
}
status = "enviado";
}
public void deliver() {
if (status.equals("enviado")) {
status = "entregado";
} else {
throw new IllegalStateException("Pedido " + status + ": no se puede entregar");
}
}
public void cancel() {
if (status.equals("entregado")) {
throw new IllegalStateException("Pedido " + status + ": no se puede cancelar");
}
if (!status.equals("nuevo")) {
System.out.println("Devolvemos el dinero al cliente");
}
status = "cancelado";
}
public String status() {
return status;
}
}

Dos pedidos:

Order first = new Order();
first.ship();
System.out.println("Primero: " + first.status());
Order second = new Order();
second.pay();
second.ship();
second.cancel();
System.out.println("Segundo: " + second.status());
Salida
Primero: enviado
Devolvemos el dinero al cliente
Segundo: cancelado

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

abstract class OrderState {
abstract String name();
OrderState pay() {
throw refuse("pagar");
}
OrderState ship() {
throw refuse("enviar");
}
OrderState deliver() {
throw refuse("entregar");
}
OrderState cancel() {
throw refuse("cancelar");
}
IllegalStateException refuse(String action) {
return new IllegalStateException("Pedido " + 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 extends OrderState {
@Override
String name() {
return "nuevo";
}
@Override
OrderState pay() {
return new PaidState();
}
@Override
OrderState cancel() {
return new CancelledState();
}
}
class PaidState extends OrderState {
@Override
String name() {
return "pagado";
}
@Override
OrderState ship() {
return new ShippedState();
}
@Override
OrderState cancel() {
System.out.println("Devolvemos el dinero al cliente");
return new CancelledState();
}
}
class ShippedState extends OrderState {
@Override
String name() {
return "enviado";
}
@Override
OrderState deliver() {
return new DeliveredState();
}
}
class DeliveredState extends OrderState {
@Override
String name() {
return "entregado";
}
}
class CancelledState extends OrderState {
@Override
String name() {
return "cancelado";
}
}

El pedido guarda el estado actual y le pasa cada evento:

class Order {
private OrderState state = new NewState();
public void pay() {
state = state.pay();
}
public void ship() {
state = state.ship();
}
public void deliver() {
state = state.deliver();
}
public void cancel() {
state = state.cancel();
}
public String status() {
return state.name();
}
}

Las dos combinaciones del problema, y luego todo el recorrido:

Order order = new Order();
try {
order.ship();
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
order.pay();
order.ship();
System.out.println(order.status());
try {
order.cancel();
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
order.deliver();
System.out.println(order.status());
Salida
Pedido nuevo: no se puede enviar
enviado
Pedido enviado: no se puede cancelar
entregado

Order order = new Order();
try {
order.ship();
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
order.pay();
order.ship();
System.out.println(order.status());
try {
order.cancel();
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
order.deliver();
System.out.println(order.status());
Paso a paso, en palabras
  1. Un pedido nuevo: su campo state apunta a NewState («nuevo»).
  2. ship() en un pedido nuevo. NewState no tiene una flecha ship, así que rechaza la clase base y catch imprime el error.
  3. pay(): NewState lo permite y devuelve PaidState. El pedido solo recuerda el estado nuevo.
  4. El mismo ship() de antes, pero ahora responde PaidState: lo permite, y el siguiente estado es ShippedState.
  5. El estado es el nombre del estado actual: enviado.
  6. cancel(), mientras el paquete va de camino. ShippedState no tiene una flecha cancel: un muro, y no se devuelve el dinero.
  7. deliver(): de «enviado» a «entregado».
  8. 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:

OrderState pack() {
throw refuse("empacar");
}

PaidState ahora permite pack en lugar de ship, y el nuevo PackedState permite ship y cancelar con devolución:

class PaidState extends OrderState {
@Override
String name() {
return "pagado";
}
@Override
OrderState pack() {
return new PackedState();
}
@Override
OrderState cancel() {
System.out.println("Devolvemos el dinero al cliente");
return new CancelledState();
}
}
class PackedState extends OrderState {
@Override
String name() {
return "empacado";
}
@Override
OrderState ship() {
return new ShippedState();
}
@Override
OrderState cancel() {
System.out.println("Devolvemos el dinero al cliente");
return new CancelledState();
}
}

El pedido recibe el método pack, que solo pasa el evento:

public void pack() {
state = state.pack();
}
Order order = new Order();
try {
order.pack();
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
order.pay();
order.pack();
order.ship();
System.out.println(order.status());
Salida
Pedido nuevo: no se puede empacar
enviado

NewState, 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.

Un enum es un tipo con una lista fija de valores. El estado es uno de ellos, y la tabla dice a qué estados puedes pasar desde cada uno:
enum Status { NEW, PAID, SHIPPED, DELIVERED, CANCELLED }
class Order {
private static final Map<Status, Set<Status>> NEXT = Map.of(
Status.NEW, Set.of(Status.PAID, Status.CANCELLED),
Status.PAID, Set.of(Status.SHIPPED, Status.CANCELLED),
Status.SHIPPED, Set.of(Status.DELIVERED),
Status.DELIVERED, Set.of(),
Status.CANCELLED, Set.of());
private Status status = Status.NEW;
public void moveTo(Status next) {
if (!NEXT.get(status).contains(next)) {
throw new IllegalStateException(status + " → " + next + ": no permitido");
}
status = next;
}
public Status status() {
return status;
}
}
Order order = new Order();
order.moveTo(Status.PAID);
order.moveTo(Status.SHIPPED);
try {
order.moveTo(Status.CANCELLED);
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
System.out.println(order.status());
Salida
SHIPPED → CANCELLED: no permitido
SHIPPED

La 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. En Java esto se escribe a menudo como un enum en el que cada valor tiene sus propios métodos (NEW { OrderState pay() { return PAID; } }). 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 = new Order();
order.pay();
order.cancel();
System.out.println(order.status());
try {
order.ship();
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
Respuesta

Pruébalo

Encuentra el error

Casi todos los estados permiten cancelar, así que alguien decide escribirlo una sola vez, en la clase base:

abstract class OrderState {
abstract String name();
OrderState pay() {
throw refuse("pagar");
}
OrderState ship() {
throw refuse("enviar");
}
OrderState deliver() {
throw refuse("entregar");
}
OrderState cancel() {
return new CancelledState();
}
IllegalStateException refuse(String action) {
return new IllegalStateException("Pedido " + name() + ": no se puede " + action);
}
}
Order order = new Order();
order.pay();
order.ship();
order.cancel();
System.out.println(order.status());
Salida
cancelado

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

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 returnOrder y un estado «devuelto». Solo está permitido desde «entregado». Lleva un pedido hasta entregado y devuélvelo, y luego intenta devolver un pedido nuevo.

Salida
Devolvemos el dinero al cliente
devuelto
Pedido nuevo: no se puede devolver

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

1. La tienda añade un evento «empacar». ¿Qué escribes en los estados donde no está permitido?
2. ¿Qué guarda el pedido con State?
3. Alguien permite cancelar por defecto, en la clase base, para no tener que escribirlo en cada estado. ¿Qué pasa con un pedido enviado?
4. ¿Cuándo bastan un enum y una tabla de transiciones, sin una clase por estado?

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