Ir al contenido
wannacode

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)
Salida
Compra
Compra

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

Un 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)
Salida
Compra leche y huevos

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)
Paso a paso, en palabras
  1. run llama a execute() del comando: el documento recibe «Compra». Luego el comando pasa a la pila de undo.
  2. Un segundo comando, encima del primero. Arriba de la pila siempre está el último que se ejecutó.
  3. Un tercero. La pila de undo recuerda el orden de todo lo que ha pasado.
  4. undo toma el comando de arriba y llama a su undo(): « y pan» se quita. El comando pasa a la pila de redo.
  5. Otro undo: también se quita « leche». Puedes retroceder tantos pasos como comandos haya.
  6. 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.
  7. Un comando nuevo. « y pan» se deshizo sobre otro texto, así que redo_stack.clear() vacía la pila de redo.
  8. 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)
Salida
Compra
Compra leche
Compra

No 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()
Error
IndexError: pop from empty list

pop() 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

El comando de escribir tiene dos acciones y nada más. En Python son dos funciones: el comando puede ser una tupla con nombre (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)
Salida
Compra
Compra leche

Las 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)
Respuesta

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)
Salida
Compra pan leche

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

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:

Salida
COMPRAR LECHE
comprar leche
COMPRAR LECHE

Más tareas

  • Una macro. Un comando que guarda una lista de comandos: execute() los ejecuta en orden, y undo() 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

1. ¿Qué guarda el historial con Command?
2. Escribes «Compra leche», deshaces « leche» y escribes « pan». ¿Qué debe pasar con la pila de redo?
3. ¿Por qué el comando de borrar guarda el texto borrado en un campo?
4. ¿Cuándo no necesitas Command?

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