Apuntes DAM
Volver al inicio

Patrón State (máquina de estados)

Patrones de diseñoDe comportamientoNivel intermedioTambién: estado, máquina de estados

Cada estado de un objeto es una clase que decide qué hacer con cada acción y a qué estado pasar: un pedido, un semáforo o el enemigo de un juego, sin un switch gigante que crece con cada estado.

Visualízalo paso a paso

Cambia los datos, dale a reproducir y sigue cada paso en el dibujo, en la línea de Java que se ejecuta y en sus variables.

State

Escribe las acciones que recibe el pedido, en orden: pagar, enviar, entregar, cancelar o devolver.

Paso 1

El pedido empieza en el estado «pendiente». Cada estado es una clase que sabe qué hacer con cada acción.

1class Pedido {
2    private EstadoPedido estado = new Pendiente();  // estado = pendiente
3
4    void accion(String evento) {
5        estado.manejar(evento, this);
6    }
7
8    void cambiar(EstadoPedido nuevo) {
9        estado = nuevo;
10    }
11}
12
13abstract class EstadoBase implements EstadoPedido {
14    public void manejar(String evento, Pedido p) {
15        System.out.println("No se puede " + evento);
16    }
17}

Variables

estado
pendiente

Atajos con el foco dentro del visualizador: ← → paso a paso, Espacio reproducir o pausar, Inicio/Fin ir al principio o al final.

La idea

Muchos objetos se comportan distinto según su estado: un pedido pendiente se puede pagar, pero uno enviado no; un reproductor en pausa reanuda con play y uno parado vuelve a empezar. Eso es una máquina de estados: un conjunto de estados, unos eventos (acciones) y unas transiciones que dicen, para cada estado y cada evento, qué pasa y a qué estado se va.

La forma ingenua de programarla es un atributo con el estado y un switch en cada método. Funciona con pocos estados, pero cada estado nuevo obliga a tocar todos los métodos, y la lógica de un mismo estado queda repartida por toda la clase.

El patrón State le da la vuelta: cada estado es una clase que implementa una interfaz con un método por evento. El objeto (el contexto) guarda una referencia a su estado actual y le delega cada acción; el estado hace lo que toque y, si hay transición, cambia el estado del contexto por otro. Todo lo que hace un estado está junto, y añadir uno es añadir una clase.

Antes de programar, conviene dibujar la máquina: un diagrama de estados o una tabla con un estado por fila y un evento por columna. Ahí se ven enseguida las transiciones que faltan y los estados sin salida.

Cuándo usarlo

  • El comportamiento de un objeto depende de su estado y cambia en tiempo de ejecución: pedidos, reservas, documentos con flujo de aprobación, reproductores, conexiones, personajes de un juego.
  • Hay switch sobre el mismo atributo de estado repetidos en varios métodos.
  • Los estados tienen bastante lógica propia (validaciones, efectos, tiempos).

Cuándo no

  • Con dos o tres estados y casi sin lógica: un enum con un switch es más sencillo y se lee en una pantalla.
  • Si las transiciones son solo datos (una tabla de qué evento lleva a qué estado), una tabla o un enum con un mapa basta.

Participantes

  1. Estado. La interfaz con un método por cada evento que puede recibir el contexto (pagar, enviar…).
  2. Estados concretos. Una clase por estado que implementa los eventos que admite y rechaza los demás. Una clase base que rechaza todo por defecto ahorra repetir código.
  3. Contexto. El objeto con estado (Pedido): guarda el estado actual, le delega cada acción y ofrece un método para cambiarlo.
  4. Transiciones. Las decide cada estado: al tratar un evento, pone en el contexto el estado siguiente.

Diagrama de clases

1estado«interface»EstadoPedido+pagar(p : Pedido) : void+enviar(p : Pedido) : void+entregar(p : Pedido) : void+cancelar(p : Pedido) : void+nombre() : StringPedido-estado : EstadoPedido+setEstado(e : EstadoPedido) : void+pagar() : void+enviar() : voidPendientePagadoEnviadoEntregado
Arrastra las clases para colocarlas a tu gusto.
Ver el diagrama en PlantUML
text
1@startuml
2interface EstadoPedido {
3  +pagar(p : Pedido) : void
4  +enviar(p : Pedido) : void
5  +entregar(p : Pedido) : void
6  +cancelar(p : Pedido) : void
7  +nombre() : String
8}
9class Pedido {
10  -estado : EstadoPedido
11  +setEstado(e : EstadoPedido) : void
12  +pagar() : void
13  +enviar() : void
14}
15class Pendiente
16class Pagado
17class Enviado
18class Entregado
19EstadoPedido <|.. Pendiente
20EstadoPedido <|.. Pagado
21EstadoPedido <|.. Enviado
22EstadoPedido <|.. Entregado
23Pedido o-- "1" EstadoPedido : estado
24@enduml

Puedes copiarlo en el editor de diagramas UML y modificarlo.

El código

Un semáforo de peatones

Aquí cada evento devuelve el estado siguiente (o el mismo, si no cambia) y el contexto lo guarda.

Java
1/** Cada estado sabe qué hacer con cada acción y a qué estado pasar. */
2interface Estado {
3    Estado pulsar();          // el botón del semáforo de peatones
4    Estado tiempo();          // ha pasado el tiempo del estado
5    String nombre();
6}
7
8class Verde implements Estado {
9    public Estado pulsar() { return new Ambar(); }           // un peatón quiere cruzar
10    public Estado tiempo() { return this; }                  // en verde se queda hasta que alguien pulse
11    public String nombre() { return "verde"; }
12}
13
14class Ambar implements Estado {
15    public Estado pulsar() { return this; }                  // ya está cambiando
16    public Estado tiempo() { return new Rojo(); }
17    public String nombre() { return "ámbar"; }
18}
19
20class Rojo implements Estado {
21    public Estado pulsar() { return this; }
22    public Estado tiempo() { return new Verde(); }
23    public String nombre() { return "rojo (cruzan los peatones)"; }
24}
25
26/** El contexto: guarda el estado actual y le delega cada acción. */
27class Semaforo {
28    private Estado estado = new Verde();
29    void pulsar() { estado = estado.pulsar(); System.out.println("pulsar → " + estado.nombre()); }
30    void tiempo() { estado = estado.tiempo(); System.out.println("tiempo → " + estado.nombre()); }
31}
32
33public class Main {
34    public static void main(String[] args) {
35        Semaforo s = new Semaforo();
36        s.tiempo();
37        s.pulsar();
38        s.pulsar();
39        s.tiempo();
40        s.tiempo();
41    }
42}

Salida al ejecutarlo (la misma en los 5 lenguajes)

tiempo → verde
pulsar → ámbar
pulsar → ámbar
tiempo → rojo (cruzan los peatones)
tiempo → verde

La alternativa con switch

Para comparar: así queda un solo evento sin el patrón.

Java
1// La alternativa sin patrón: un switch en cada acción. Con 4 estados y 4 acciones son 16 casos
2// repartidos por la clase, y cada estado nuevo obliga a tocar todos los métodos.
3void enviar() {
4    switch (estado) {
5        case PENDIENTE -> System.out.println("No se puede enviar un pedido sin pagar");
6        case PAGADO -> estado = Estado.ENVIADO;
7        case ENVIADO, ENTREGADO -> System.out.println("Ya se envió");
8        case CANCELADO -> System.out.println("El pedido está cancelado");
9    }
10}

Traza: transiciones del pedido

Estado \ eventopagarenviarentregarcancelardevolver
pendiente→ pagado✗✗→ cancelado✗
pagadoya está pagado→ enviado✗→ cancelado (reembolso)✗
enviado✗✗→ entregadoen camino: ✗✗
entregado✗✗✗✗→ devuelto (14 días)
cancelado, devuelto✗✗✗✗✗

La tabla de transiciones es la especificación: cada fila es una clase de estado y cada celda, un método. Las ✗ las resuelve la clase base rechazando la acción.

En la práctica

  • El ciclo de vida de una Activity de Android y la máquina de estados de MediaPlayer son máquinas de estados (hay un ejercicio largo de cada una).
  • Los protocolos de red: TCP pasa por LISTEN, SYN_SENT, ESTABLISHED, CLOSE_WAIT…
  • La IA de los enemigos de un juego (patrulla, persigue, ataca, huye) y los analizadores léxicos (autómatas finitos).
  • Los flujos de trabajo de una empresa (una factura borrador, emitida, cobrada, anulada) y librerías como Spring State Machine.

Errores típicos

  • Dejar que el contexto decida las transiciones con if sobre el tipo del estado: se vuelve al switch con más clases.
  • Olvidar una combinación de estado y evento: con la clase base que rechaza todo, un evento no previsto da un mensaje claro en lugar de un comportamiento raro.
  • Estados que guardan datos que deberían estar en el contexto (el importe, la fecha): al cambiar de estado se pierden.
  • Crear un objeto de estado nuevo en cada transición cuando el estado no tiene datos propios: se puede reutilizar una sola instancia (o usar un enum).
  • No dibujar la máquina antes: aparecen estados sin salida o eventos que no llevan a ninguna parte.

Ejercicios

Cada ejercicio se corrige solo con sus pruebas (algunas ocultas). Escribe tu solución en el editor y pulsa Ejecutar o Comprobar; la solución explicada está debajo, por si te atascas.

1. El pedido de una tienda online

Completa los estados del pedido con el patrón State. La interfaz, la clase base que rechaza cualquier acción (No se puede enviar un pedido pendiente) y el estado Pendiente ya están escritos, igual que el contexto Pedido, que escribe cada transición. Redefine en cada estado las acciones que admite según la tabla de transiciones de la ficha.

  • Órdenes: pagar, enviar, entregar, cancelar, devolver y esperar N (pasan N días). Cada transición escribe pendiente → pagado (lo hace Pedido.cambiar).
  • Mensajes propios: pagar un pedido pagado, Ya está pagado; cancelar uno pagado cambia a cancelado y escribe después Se devuelve el importe al cliente; cancelar uno enviado, Ya está en camino: espera a la entrega y devuélvelo; devolver uno entregado hace más de 14 días, Han pasado más de 14 días: ya no se puede devolver.
  • Al final, Historial: pendiente → pagado → ….
☕JavaEl pedido de una tienda onlineMedio

Ejemplo

Entrada (lo que se escribe por teclado)
enviar
pagar
pagar
enviar
cancelar
entregar
esperar 3
devolver
pagar
Salida esperada
No se puede enviar un pedido pendiente
pendiente → pagado
Ya está pagado
pagado → enviado
Ya está en camino: espera a la entrega y devuélvelo
enviado → entregado
entregado → devuelto
No se puede pagar un pedido devuelto
Historial: pendiente → pagado → enviado → entregado → devuelto
⏳
Test oculto #3
⏳
Test oculto #4
⏳
Test oculto #5
0/5 tests pasados · pulsa un test para ver su entrada y su salida esperada
Ver la solución explicada
java
1import java.util.*;
2
3/** Las acciones del pedido. Cada estado decide qué pasa y a qué estado se va. */
4interface EstadoPedido {
5    void pagar(Pedido p);
6    void enviar(Pedido p);
7    void entregar(Pedido p);
8    void cancelar(Pedido p);
9    void devolver(Pedido p);
10    String nombre();
11}
12
13/** Por defecto, ninguna acción está permitida: cada estado redefine las suyas. */
14abstract class EstadoBase implements EstadoPedido {
15    private void rechazar(String accion) {
16        System.out.println("No se puede " + accion + " un pedido " + nombre());
17    }
18    public void pagar(Pedido p) { rechazar("pagar"); }
19    public void enviar(Pedido p) { rechazar("enviar"); }
20    public void entregar(Pedido p) { rechazar("entregar"); }
21    public void cancelar(Pedido p) { rechazar("cancelar"); }
22    public void devolver(Pedido p) { rechazar("devolver"); }
23}
24
25class Pendiente extends EstadoBase {
26    public String nombre() { return "pendiente"; }
27    public void pagar(Pedido p) { p.cambiar(new Pagado()); }
28    public void cancelar(Pedido p) { p.cambiar(new Cancelado()); }
29}
30
31class Pagado extends EstadoBase {
32    public String nombre() { return "pagado"; }
33    public void pagar(Pedido p) { System.out.println("Ya está pagado"); }
34    public void enviar(Pedido p) { p.cambiar(new Enviado()); }
35    public void cancelar(Pedido p) {
36        p.cambiar(new Cancelado());
37        System.out.println("Se devuelve el importe al cliente");
38    }
39}
40
41class Enviado extends EstadoBase {
42    public String nombre() { return "enviado"; }
43    public void entregar(Pedido p) { p.cambiar(new Entregado()); }
44    public void cancelar(Pedido p) { System.out.println("Ya está en camino: espera a la entrega y devuélvelo"); }
45}
46
47class Entregado extends EstadoBase {
48    public String nombre() { return "entregado"; }
49    public void devolver(Pedido p) {
50        if (p.diasDesdeEntrega() > 14) System.out.println("Han pasado más de 14 días: ya no se puede devolver");
51        else p.cambiar(new Devuelto());
52    }
53}
54
55class Cancelado extends EstadoBase {
56    public String nombre() { return "cancelado"; }
57}
58
59class Devuelto extends EstadoBase {
60    public String nombre() { return "devuelto"; }
61}
62
63/** El contexto: delega cada acción en su estado actual. */
64class Pedido {
65    private EstadoPedido estado = new Pendiente();
66    private final List<String> historial = new ArrayList<>(List.of("pendiente"));
67    private int dias = 0, diaEntrega = -1;
68
69    void cambiar(EstadoPedido nuevo) {
70        System.out.println(estado.nombre() + " → " + nuevo.nombre());
71        estado = nuevo;
72        historial.add(nuevo.nombre());
73        if (nuevo.nombre().equals("entregado")) diaEntrega = dias;
74    }
75
76    int diasDesdeEntrega() { return dias - diaEntrega; }
77    void pasarDias(int n) { dias += n; }
78    String historial() { return String.join(" → ", historial); }
79    EstadoPedido estado() { return estado; }
80}
81
82public class Main {
83    public static void main(String[] args) {
84        Scanner sc = new Scanner(System.in);
85        Pedido p = new Pedido();
86        while (sc.hasNextLine()) {
87            String linea = sc.nextLine().trim();
88            if (linea.isEmpty()) continue;
89            String[] w = linea.split("\\s+");
90            switch (w[0]) {
91                case "pagar" -> p.estado().pagar(p);
92                case "enviar" -> p.estado().enviar(p);
93                case "entregar" -> p.estado().entregar(p);
94                case "cancelar" -> p.estado().cancelar(p);
95                case "devolver" -> p.estado().devolver(p);
96                case "esperar" -> {
97                    try {
98                        p.pasarDias(Integer.parseInt(w[1]));
99                    } catch (RuntimeException e) {
100                        System.out.println("Orden no válida: " + linea);
101                    }
102                }
103                default -> System.out.println("Orden no válida: " + linea);
104            }
105        }
106        System.out.println("Historial: " + p.historial());
107    }
108}

Cada clase contiene exactamente una fila de la tabla de transiciones: lo que se puede hacer en ese estado. Las combinaciones no permitidas las resuelve la clase base con un mensaje genérico, así que no hay casos olvidados.

Añadir un estado nuevo (por ejemplo, «en almacén» entre pagado y enviado) es escribir una clase y cambiar una transición, sin tocar el resto.

2. Una máquina expendedora

Una máquina expendedora tiene tres estados: en reposo (sin dinero, ya escrito), con saldo y sin productos (todo agotado). Programa los dos que faltan. Con saldo: las monedas suman; al elegir un producto, si existe, quedan unidades y el saldo llega, sale el producto, se devuelve el cambio y se vuelve a reposo (o a sin productos si era la última unidad de todo); cancelar devuelve el saldo. Sin productos: no acepta monedas (las devuelve) ni ventas. El dinero va en céntimos y el contexto ya sabe devolver el cambio con las monedas más grandes.

  • Órdenes: moneda 0.50 (monedas de 2, 1, 0,50, 0,20, 0,10 y 0,05 €), elegir A1, cancelar y reponer A1 3. Productos: A1 agua (1,00 €, 2 uds.), A2 refresco (1,50 €, 1 ud.) y B1 patatas (1,20 €, 1 ud.).
  • Con saldo: Saldo: 1,50 € al añadir; Código desconocido: X, refresco: agotado, Faltan 0,30 € para patatas o Sale agua seguido del cambio (Devuelve 0,50 €: 1 × 0,50 €); cancelar devuelve todo el saldo así.
  • Sin productos: Fuera de servicio: se devuelve la moneda de 1,00 €, Fuera de servicio al elegir y No hay nada que devolver al cancelar. Cada cambio de estado escribe [en reposo → con saldo].
☕JavaUna máquina expendedoraDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
elegir A1
moneda 2
elegir B1
moneda 0.5
moneda 0.2
elegir A2
cancelar
Salida esperada
Introduce 1,00 € para agua
[en reposo → con saldo]
Saldo: 2,00 €
Sale patatas
Devuelve 0,80 €: 1 × 0,50 € + 1 × 0,20 € + 1 × 0,10 €
[con saldo → en reposo]
[en reposo → con saldo]
Saldo: 0,50 €
Saldo: 0,70 €
Faltan 0,80 € para refresco
Devuelve 0,70 €: 1 × 0,50 € + 1 × 0,20 €
[con saldo → en reposo]
Estado final: en reposo
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada
Ver la solución explicada
java
1import java.util.*;
2
3interface EstadoMaquina {
4    void moneda(Maquina m, int centimos);
5    void elegir(Maquina m, String codigo);
6    void cancelar(Maquina m);
7    String nombre();
8}
9
10/** Sin dinero dentro. */
11class Reposo implements EstadoMaquina {
12    public void moneda(Maquina m, int c) {
13        m.saldo = c;
14        m.cambiar(new ConSaldo());
15        System.out.println("Saldo: " + Maquina.euros(m.saldo));
16    }
17    public void elegir(Maquina m, String codigo) {
18        Producto p = m.productos.get(codigo);
19        System.out.println(p == null ? "Código desconocido: " + codigo : "Introduce " + Maquina.euros(p.precio) + " para " + p.nombre);
20    }
21    public void cancelar(Maquina m) { System.out.println("No hay nada que devolver"); }
22    public String nombre() { return "en reposo"; }
23}
24
25/** Con dinero dentro, esperando a que se elija un producto. */
26class ConSaldo implements EstadoMaquina {
27    public void moneda(Maquina m, int c) {
28        m.saldo += c;
29        System.out.println("Saldo: " + Maquina.euros(m.saldo));
30    }
31
32    public void elegir(Maquina m, String codigo) {
33        Producto p = m.productos.get(codigo);
34        if (p == null) System.out.println("Código desconocido: " + codigo);
35        else if (p.stock == 0) System.out.println(p.nombre + ": agotado");
36        else if (m.saldo < p.precio) System.out.println("Faltan " + Maquina.euros(p.precio - m.saldo) + " para " + p.nombre);
37        else {
38            p.stock--;
39            System.out.println("Sale " + p.nombre);
40            m.devolverCambio(m.saldo - p.precio);
41            m.saldo = 0;
42            m.cambiar(m.quedanProductos() ? new Reposo() : new SinProductos());
43        }
44    }
45
46    public void cancelar(Maquina m) {
47        m.devolverCambio(m.saldo);
48        m.saldo = 0;
49        m.cambiar(new Reposo());
50    }
51
52    public String nombre() { return "con saldo"; }
53}
54
55/** Todo agotado: no acepta dinero hasta que se reponga. */
56class SinProductos implements EstadoMaquina {
57    public void moneda(Maquina m, int c) { System.out.println("Fuera de servicio: se devuelve la moneda de " + Maquina.euros(c)); }
58    public void elegir(Maquina m, String codigo) { System.out.println("Fuera de servicio"); }
59    public void cancelar(Maquina m) { System.out.println("No hay nada que devolver"); }
60    public String nombre() { return "sin productos"; }
61}
62
63class Producto {
64    final String nombre;
65    final int precio;
66    int stock;
67    Producto(String nombre, int precio, int stock) { this.nombre = nombre; this.precio = precio; this.stock = stock; }
68}
69
70/** El contexto. El dinero va en céntimos: con double, 0,10 + 0,20 no da 0,30 exacto. */
71class Maquina {
72    static final int[] MONEDAS = {200, 100, 50, 20, 10, 5};
73    final Map<String, Producto> productos = new LinkedHashMap<>();
74    EstadoMaquina estado = new Reposo();
75    int saldo;
76
77    static String euros(int c) {
78        return c / 100 + "," + String.format("%02d", c % 100) + " €";
79    }
80
81    void cambiar(EstadoMaquina nuevo) {
82        System.out.println("[" + estado.nombre() + " → " + nuevo.nombre() + "]");
83        estado = nuevo;
84    }
85
86    boolean quedanProductos() {
87        return productos.values().stream().anyMatch(p -> p.stock > 0);
88    }
89
90    /** Devuelve el importe con las monedas más grandes posibles. */
91    void devolverCambio(int c) {
92        if (c == 0) return;
93        List<String> monedas = new ArrayList<>();
94        int resto = c;
95        for (int m : MONEDAS) {
96            int n = resto / m;
97            if (n > 0) monedas.add(n + " × " + euros(m));
98            resto %= m;
99        }
100        System.out.println("Devuelve " + euros(c) + ": " + String.join(" + ", monedas));
101    }
102
103    void reponer(String codigo, int unidades) {
104        Producto p = productos.get(codigo);
105        if (p == null || unidades <= 0) {
106            System.out.println("No se puede reponer " + codigo);
107            return;
108        }
109        if (estado instanceof ConSaldo) {
110            System.out.println("No se puede reponer con dinero dentro");
111            return;
112        }
113        p.stock += unidades;
114        System.out.println("Repuesto " + p.nombre + ": " + p.stock + " unidades");
115        if (estado instanceof SinProductos) cambiar(new Reposo());
116    }
117}
118
119public class Main {
120    public static void main(String[] args) {
121        Scanner sc = new Scanner(System.in);
122        Maquina m = new Maquina();
123        m.productos.put("A1", new Producto("agua", 100, 2));
124        m.productos.put("A2", new Producto("refresco", 150, 1));
125        m.productos.put("B1", new Producto("patatas", 120, 1));
126        while (sc.hasNextLine()) {
127            String linea = sc.nextLine().trim();
128            if (linea.isEmpty()) continue;
129            String[] w = linea.split("\\s+");
130            try {
131                switch (w[0]) {
132                    case "moneda" -> {
133                        int c = (int) Math.round(Double.parseDouble(w[1].replace(',', '.')) * 100);
134                        if (Arrays.stream(Maquina.MONEDAS).noneMatch(x -> x == c)) System.out.println("Moneda no admitida: " + w[1]);
135                        else m.estado.moneda(m, c);
136                    }
137                    case "elegir" -> m.estado.elegir(m, w[1]);
138                    case "cancelar" -> m.estado.cancelar(m);
139                    case "reponer" -> m.reponer(w[1], Integer.parseInt(w[2]));
140                    default -> throw new IllegalArgumentException();
141                }
142            } catch (RuntimeException e) {
143                System.out.println("Orden no válida: " + linea);
144            }
145        }
146        System.out.println("Estado final: " + m.estado.nombre() + (m.saldo > 0 ? " (saldo " + Maquina.euros(m.saldo) + ")" : ""));
147    }
148}

Los tres estados responden a las mismas tres acciones de formas distintas, y ninguno necesita preguntar en qué estado está: lo sabe por ser la clase que es.

Las reglas que no dependen del estado (reponer, devolver el cambio, el formato del dinero) se quedan en el contexto; las que sí dependen, en los estados. Es la frontera que hay que decidir en cada máquina.

Test

Test: State (máquina de estados)

0/5 respondidas · 0 aciertos

Elige una respuesta en cada pregunta: verás al momento si es correcta y por qué. Con un 80 % de aciertos se da por superada.

  1. 1.En el patrón State, ¿quién decide a qué estado se pasa tras un evento?

  2. 2.¿Qué ventaja tiene sobre un switch en cada método?

  3. 3.¿Para qué sirve una clase base que rechaza todas las acciones por defecto?

  4. 4.¿Qué patrón tiene casi la misma estructura de clases que State?

  5. 5.Con tres estados sin apenas lógica, ¿qué suele ser más sencillo?

Relacionado