Command
En resumen: Command convierte cada acción en un objeto con execute y undo, de modo que las acciones se guardan en un historial y se pueden deshacer y rehacer.
Problema
Un deshacer que recuerda una sola cosa
El pequeño editor de notas escribe texto y borra desde el final. Deshacer se hace así: el editor guarda, como texto, cuál fue la última acción y qué texto intervino.
class Editor { private String text = ""; private String lastAction = ""; private String lastText = "";
public void type(String s) { text += s; lastAction = "type"; lastText = s; }
public void deleteLast(int count) { lastText = text.substring(text.length() - count); text = text.substring(0, text.length() - count); lastAction = "delete"; }
public void undo() { if (lastAction.equals("type")) { text = text.substring(0, text.length() - lastText.length()); } else if (lastAction.equals("delete")) { text += lastText; } lastAction = ""; }
public String text() { return text; }}Editor editor = new Editor();editor.type("Compra");editor.type(" leche");editor.undo();System.out.println(editor.text());editor.undo();System.out.println(editor.text());CompraCompraEl primer deshacer quitó « leche». El segundo no hizo nada: «Compra» se quedó, porque el editor recuerda una sola acción y, tras deshacer, hasta esa desaparece. No hay rehacer en absoluto: el texto que quitó el deshacer se pierde.
Cada acción nueva (negrita, reemplazar, pegar) es una rama más en undo() y más campos en el editor. El conocimiento de cómo deshacer una acción vive lejos de la acción misma.
La idea
Command: la acción como objeto
En lugar de «la última acción fue escribir», cada acción se convierte en un objeto que sabe dos cosas: cómo ejecutarse (execute()) y cómo volver atrás (undo()). Lo que necesita para volver atrás lo guarda en sus propios campos.
El historial guarda los comandos en dos pilas:
- la pila de undo: los comandos que se ejecutaron. Deshacer toma el de arriba, llama a
undo()y lo pone en la pila de redo; - la pila de redo: los comandos que se deshicieron. Rehacer toma el de arriba, lo ejecuta otra vez y lo devuelve a la pila de undo;
- un comando nuevo vacía la pila de redo. Los comandos deshechos se hicieron sobre otro texto, y no hay dónde repetirlos.
Este es el patrón Command. Tiene tres papeles: el comando (la acción como objeto), el receptor (el documento sobre el que trabaja) y el historial, que llama a los comandos sin saber qué hacen.
Míralo
Dos pilas
El documento solo sabe añadir y borrar desde el final. removeLast devuelve el texto borrado:
class Document { private String text = "";
public void append(String s) { text += s; }
public String removeLast(int count) { String removed = text.substring(text.length() - count); text = text.substring(0, text.length() - count); return removed; }
public String text() { return text; }}La forma común de todo comando:
interface Command { void execute(); void undo();}Un comando para escribir. Guarda el documento y su texto, así que sabe cuánto borrar al deshacer:
class TypeText implements Command { private final Document doc; private final String text;
public TypeText(Document doc, String text) { this.doc = doc; this.text = text; }
@Override public void execute() { doc.append(text); }
@Override public void undo() { doc.removeLast(text.length()); }}El historial no sabe qué son los comandos. Solo los llama y los mueve entre las pilas:
class History { private final Deque<Command> undoStack = new ArrayDeque<>(); private final Deque<Command> redoStack = new ArrayDeque<>();
public void run(Command command) { command.execute(); undoStack.push(command); redoStack.clear(); }
public void undo() { if (undoStack.isEmpty()) { return; } Command command = undoStack.pop(); command.undo(); redoStack.push(command); }
public void redo() { if (redoStack.isEmpty()) { return; } Command command = redoStack.pop(); command.execute(); undoStack.push(command); }}Document doc = new Document();History history = new History();history.run(new TypeText(doc, "Compra"));history.run(new TypeText(doc, " leche"));history.run(new TypeText(doc, " y pan"));history.undo();history.undo();history.redo();history.run(new TypeText(doc, " y huevos"));System.out.println(doc.text());Compra leche y huevosDocument doc = new Document();History history = new History();history.run(new TypeText(doc, "Compra"));history.run(new TypeText(doc, " leche"));history.run(new TypeText(doc, " y pan"));history.undo();history.undo();history.redo();history.run(new TypeText(doc, " y huevos"));System.out.println(doc.text());Paso a paso, en palabras
- run llama a execute() del comando: el documento recibe «Compra». Luego el comando pasa a la pila de undo.
- Un segundo comando, encima del primero. Arriba de la pila siempre está el último que se ejecutó.
- Un tercero. La pila de undo recuerda el orden de todo lo que ha pasado.
- undo toma el comando de arriba y llama a su undo(): « y pan» se quita. El comando pasa a la pila de redo.
- Otro undo: también se quita « leche». Puedes retroceder tantos pasos como comandos haya.
- redo toma la de arriba de la pila de redo y llama otra vez a execute(): « leche» vuelve. El comando regresa a la pila de undo.
- Un comando nuevo. « y pan» se deshizo sobre otro texto, así que redoStack.clear() vacía la pila de redo.
- El documento es «Compra leche y huevos». Eso es lo que imprime el programa.
Deshacer en tantos pasos como quieras y rehacer salen de dos pilas y tres métodos cortos. Un tipo nuevo de acción es una clase nueva, y el historial no se toca.
Míralo
Un comando que recuerda
Borrar no se puede deshacer con el número de letras: hay que saber qué se borró. El comando lo recuerda en execute():
class DeleteLast implements Command { private final Document doc; private final int count; private String removed = "";
public DeleteLast(Document doc, int count) { this.doc = doc; this.count = count; }
@Override public void execute() { removed = doc.removeLast(count); }
@Override public void undo() { doc.append(removed); }}Document doc = new Document();History history = new History();history.run(new TypeText(doc, "Compra leche"));history.run(new DeleteLast(doc, 6));System.out.println(doc.text());history.undo();System.out.println(doc.text());history.redo();System.out.println(doc.text());CompraCompra lecheCompraNo cambió ni una sola línea de History ni de TypeText.
La comprobación al principio de undo() no es decoración. Sin ella, un deshacer con el historial vacío detiene el programa:
public void undo() { Command command = undoStack.pop(); command.undo(); redoStack.push(command);}History history = new History();history.undo();Exception in thread "main" java.util.NoSuchElementExceptionpop() sobre un ArrayDeque vacío lanza NoSuchElementException. La gente pulsa Ctrl+Z más veces de las que escribió, y es normal, así que una pila vacía significa «nada que deshacer».
Míralo
Un comando hecho de dos funciones
Command tiene dos métodos, así que no es una interfaz funcional, y una lambda no sirve. Una clase anónima hace lo mismo que dos lambdas: ve doc y text del método que la crea, e History no nota la diferencia:static Command typeText(Document doc, String text) { return new Command() { @Override public void execute() { doc.append(text); }
@Override public void undo() { doc.removeLast(text.length()); } };}Document doc = new Document();History history = new History();history.run(typeText(doc, "Compra"));history.run(typeText(doc, " leche"));history.undo();System.out.println(doc.text());history.redo();System.out.println(doc.text());CompraCompra lecheLas lambdas capturan (recuerdan) doc y text, igual que los campos de la clase los guardaban. En Python y JavaScript este es el camino habitual cuando el comando es corto: el comando son solo dos funciones.
Una clase compensa cuando el comando recuerda algo que cambia entre execute() y undo(), como el texto borrado. Las funciones también pueden, pero el estado se esconde en la clausura y es más difícil de leer.
Pruébalo
Predice la salida
Se escribe una nota sobre una reunión, se deshace y se vuelve a escribir. ¿Qué imprime el programa?
Document doc = new Document();History history = new History();history.run(new TypeText(doc, "Reunión"));history.run(new TypeText(doc, " a las 10:00"));history.undo();history.run(new TypeText(doc, " a las 11:00"));history.redo();history.undo();System.out.println(doc.text());Pruébalo
Encuentra el error
Alguien vuelve a escribir el historial:
class History { private final Deque<Command> undoStack = new ArrayDeque<>(); private final Deque<Command> redoStack = new ArrayDeque<>();
public void run(Command command) { command.execute(); undoStack.push(command); }
public void undo() { if (undoStack.isEmpty()) { return; } Command command = undoStack.pop(); command.undo(); redoStack.push(command); }
public void redo() { if (redoStack.isEmpty()) { return; } Command command = redoStack.pop(); command.execute(); undoStack.push(command); }}Document doc = new Document();History history = new History();history.run(new TypeText(doc, "Compra"));history.run(new TypeText(doc, " leche"));history.undo();history.run(new TypeText(doc, " pan"));history.redo();System.out.println(doc.text());Compra pan lecheRedo puso « leche» en una nota donde « leche» se había deshecho antes de escribir « pan».
Elige la línea después de la cual debía ir una más.
run pone el comando nuevo en la pila de undo, pero no vacía la pila de redo. « leche» se queda ahí, y redo la repite sobre un texto al que ya no le sirve. Con comandos más complejos es aún peor: un borrado repetido sobre otro texto borra las letras equivocadas. Después de esta línea tiene que ir redoStack.clear();. Entonces rehacer después de un comando nuevo no hace nada, y la nota es «Compra pan».
La idea
Cuándo no lo necesitas
Command tiene un costo: una clase (o dos funciones) por cada acción, en lugar de llamar al método directamente. No compensa cuando:
- la acción se llama enseguida, no hay deshacer, no se pone en cola y no se registra. Entonces el comando es un envoltorio alrededor de una llamada;
- el estado es pequeño. Si el documento es un texto corto, el historial puede guardar copias del texto antes de cada acción, y deshacer es «volver a la copia anterior». Es más simple y no puede equivocarse con la acción inversa;
- deshacer hace falta solo para un paso, en un lugar.
Compensa cuando las acciones se tienen que deshacer en muchos pasos, repetir, esperar en una cola (trabajos por enviar), registrarse y reproducirse de nuevo (una macro) o ejecutarse más tarde.
Ejercicio
Mayúsculas
Resuelve la tarea en tu editor.
Mayúsculas. Añade un comando Upper que ponga todo el texto en mayúsculas y cuyo deshacer devuelva el texto como estaba. Document e History no se tocan. Escribe «comprar leche», ejecuta Upper, luego deshaz y rehaz, e imprime el texto después de cada uno de los tres pasos:
COMPRAR LECHEcomprar lecheCOMPRAR LECHEMás tareas
- Una macro. Un comando que guarda una lista de comandos:
execute()los ejecuta en orden, yundo()los deshace en orden inverso. Es un Composite de comandos. ¿Por qué el orden se invierte al deshacer? - Solo 3 pasos atrás. El historial recuerda como máximo 3 comandos para deshacer. El más antiguo se descarta.
- El carrito. Comandos «añadir un producto» y «quitar un producto» para el carrito de una tienda, con deshacer y rehacer. ¿Qué tiene que recordar «quitar»?
Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Clases anidadas · Lambdas · Patrones de diseño · Composite
- Continúa en: La tienda: qué patrón dónde
- Ejercicios sobre el tema: Patrones de diseño