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: def __init__(self): self.text = "" self.last_action = "" self.last_text = ""
def type(self, s): self.text += s self.last_action = "type" self.last_text = s
def delete_last(self, count): self.last_text = self.text[-count:] self.text = self.text[:-count] self.last_action = "delete"
def undo(self): if self.last_action == "type": self.text = self.text[: -len(self.last_text)] elif self.last_action == "delete": self.text += self.last_text self.last_action = ""editor = Editor()editor.type("Compra")editor.type(" leche")editor.undo()print(editor.text)editor.undo()print(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. remove_last devuelve el texto borrado:
class Document: def __init__(self): self.text = ""
def append(self, s): self.text += s
def remove_last(self, count): removed = self.text[-count:] self.text = self.text[:-count] return removedUn comando para escribir. Guarda el documento y su texto, así que sabe cuánto borrar al deshacer:
class TypeText: def __init__(self, doc, text): self.doc = doc self.text = text
def execute(self): self.doc.append(self.text)
def undo(self): self.doc.remove_last(len(self.text))El historial no sabe qué son los comandos. Solo los llama y los mueve entre las pilas:
class History: def __init__(self): self.undo_stack = [] self.redo_stack = []
def run(self, command): command.execute() self.undo_stack.append(command) self.redo_stack.clear()
def undo(self): if not self.undo_stack: return command = self.undo_stack.pop() command.undo() self.redo_stack.append(command)
def redo(self): if not self.redo_stack: return command = self.redo_stack.pop() command.execute() self.undo_stack.append(command)doc = Document()history = History()history.run(TypeText(doc, "Compra"))history.run(TypeText(doc, " leche"))history.run(TypeText(doc, " y pan"))history.undo()history.undo()history.redo()history.run(TypeText(doc, " y huevos"))print(doc.text)Compra leche y huevosdoc = Document()history = History()history.run(TypeText(doc, "Compra"))history.run(TypeText(doc, " leche"))history.run(TypeText(doc, " y pan"))history.undo()history.undo()history.redo()history.run(TypeText(doc, " y huevos"))print(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 redo_stack.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: def __init__(self, doc, count): self.doc = doc self.count = count self.removed = ""
def execute(self): self.removed = self.doc.remove_last(self.count)
def undo(self): self.doc.append(self.removed)doc = Document()history = History()history.run(TypeText(doc, "Compra leche"))history.run(DeleteLast(doc, 6))print(doc.text)history.undo()print(doc.text)history.redo()print(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:
def undo(self): command = self.undo_stack.pop() command.undo() self.redo_stack.append(command)history = History()history.undo()IndexError: pop from empty listpop() sobre una lista vacía lanza IndexError. 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
namedtuple) de dos lambdas. History llama a command.execute() y a command.undo() y no nota la diferencia:from collections import namedtuple
Command = namedtuple("Command", ["execute", "undo"])
def type_text(doc, text): return Command( execute=lambda: doc.append(text), undo=lambda: doc.remove_last(len(text)), )doc = Document()history = History()history.run(type_text(doc, "Compra"))history.run(type_text(doc, " leche"))history.undo()print(doc.text)history.redo()print(doc.text)CompraCompra lecheLas lambdas capturan (recuerdan) doc y text, igual que los campos de la clase los guardaban. En Python es el camino habitual cuando el comando es corto. En Java se hace lo mismo con una clase anónima, y en C# con dos Action.
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?
doc = Document()history = History()history.run(TypeText(doc, "Reunión"))history.run(TypeText(doc, " a las 10:00"))history.undo()history.run(TypeText(doc, " a las 11:00"))history.redo()history.undo()print(doc.text)Pruébalo
Encuentra el error
Alguien vuelve a escribir el historial:
class History: def __init__(self): self.undo_stack = [] self.redo_stack = []
def run(self, command): command.execute() self.undo_stack.append(command)
def undo(self): if not self.undo_stack: return command = self.undo_stack.pop() command.undo() self.redo_stack.append(command)
def redo(self): if not self.redo_stack: return command = self.redo_stack.pop() command.execute() self.undo_stack.append(command)doc = Document()history = History()history.run(TypeText(doc, "Compra"))history.run(TypeText(doc, " leche"))history.undo()history.run(TypeText(doc, " pan"))history.redo()print(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 self.redo_stack.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 y datos · Lambdas · Patrones de diseño · Composite
- Continúa en: La tienda: qué patrón dónde
- Ejercicios sobre el tema: Patrones de diseño