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 {
constructor() {
this.text = "";
this.lastAction = "";
this.lastText = "";
}
type(s) {
this.text += s;
this.lastAction = "type";
this.lastText = s;
}
deleteLast(count) {
this.lastText = this.text.slice(-count);
this.text = this.text.slice(0, -count);
this.lastAction = "delete";
}
undo() {
if (this.lastAction === "type") {
this.text = this.text.slice(0, -this.lastText.length);
} else if (this.lastAction === "delete") {
this.text += this.lastText;
}
this.lastAction = "";
}
}
const editor = new Editor();
editor.type("Compra");
editor.type(" leche");
editor.undo();
console.log(editor.text);
editor.undo();
console.log(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. removeLast devuelve el texto borrado:

class Document {
constructor() {
this.text = "";
}
append(s) {
this.text += s;
}
removeLast(count) {
const removed = this.text.slice(-count);
this.text = this.text.slice(0, -count);
return removed;
}
}

Un comando para escribir. Guarda el documento y su texto, así que sabe cuánto borrar al deshacer:

class TypeText {
constructor(doc, text) {
this.doc = doc;
this.text = text;
}
execute() {
this.doc.append(this.text);
}
undo() {
this.doc.removeLast(this.text.length);
}
}

El historial no sabe qué son los comandos. Solo los llama y los mueve entre las pilas:

class History {
constructor() {
this.undoStack = [];
this.redoStack = [];
}
run(command) {
command.execute();
this.undoStack.push(command);
this.redoStack.length = 0;
}
undo() {
if (this.undoStack.length === 0) {
return;
}
const command = this.undoStack.pop();
command.undo();
this.redoStack.push(command);
}
redo() {
if (this.redoStack.length === 0) {
return;
}
const command = this.redoStack.pop();
command.execute();
this.undoStack.push(command);
}
}
const doc = new Document();
const 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"));
console.log(doc.text);
Salida
Compra leche y huevos

const doc = new Document();
const 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"));
console.log(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 redoStack.length = 0 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 {
constructor(doc, count) {
this.doc = doc;
this.count = count;
this.removed = "";
}
execute() {
this.removed = this.doc.removeLast(this.count);
}
undo() {
this.doc.append(this.removed);
}
}
const doc = new Document();
const history = new History();
history.run(new TypeText(doc, "Compra leche"));
history.run(new DeleteLast(doc, 6));
console.log(doc.text);
history.undo();
console.log(doc.text);
history.redo();
console.log(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:

undo() {
const command = this.undoStack.pop();
command.undo();
this.redoStack.push(command);
}
const history = new History();
history.undo();
Error
TypeError: Cannot read properties of undefined (reading 'undo')

pop() sobre un array vacío no lanza nada, devuelve undefined. El error aparece en la línea siguiente, cuando undefined no tiene el método undo. 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 JavaScript es un objeto con dos funciones flecha. History llama a command.execute() y a command.undo() y no nota la diferencia:
function typeText(doc, text) {
return {
execute: () => doc.append(text),
undo: () => doc.removeLast(text.length),
};
}
const doc = new Document();
const history = new History();
history.run(typeText(doc, "Compra"));
history.run(typeText(doc, " leche"));
history.undo();
console.log(doc.text);
history.redo();
console.log(doc.text);
Salida
Compra
Compra leche

Las lambdas capturan (recuerdan) doc y text, igual que los campos de la clase los guardaban. En JavaScript 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?

const doc = new Document();
const 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();
console.log(doc.text);
Respuesta

Pruébalo

Encuentra el error

Alguien vuelve a escribir el historial:

class History {
constructor() {
this.undoStack = [];
this.redoStack = [];
}
run(command) {
command.execute();
this.undoStack.push(command);
}
undo() {
if (this.undoStack.length === 0) {
return;
}
const command = this.undoStack.pop();
command.undo();
this.redoStack.push(command);
}
redo() {
if (this.redoStack.length === 0) {
return;
}
const command = this.redoStack.pop();
command.execute();
this.undoStack.push(command);
}
}
const doc = new Document();
const 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();
console.log(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 ·