Implementing Undo/Redo with the Command Pattern in Java

The ability to undo and redo operations is a critical feature in many applications, from text editors and graphic design tools to complex business applications. Implementing this functionality in a clean, maintainable, and extensible way can be challenging. The Command Pattern provides an elegant solution by encapsulating requests as objects, thereby allowing you to parameterize clients with queues, requests, and operations, and supporting undoable operations.

This article explores how to implement a robust undo/redo system using the Command Pattern in Java.


Understanding the Command Pattern

The Command Pattern is a behavioral design pattern that turns a request into a stand-alone object containing all information about the request. This transformation lets you parameterize methods with different requests, delay or queue a request's execution, and support undoable operations.

Key Components for Undo/Redo

  1. Command Interface: Declares an interface for executing operations and undoing them.
  2. ConcreteCommand Classes: Implement the Command interface and define a binding between a Receiver object and an action.
  3. Receiver: Knows how to perform the operations associated with carrying out a request.
  4. Invoker: Asks the command to carry out the request and maintains the history of executed commands for undo/redo.
  5. Client: Creates ConcreteCommand objects and sets their receivers.

Implementation: A Text Editor with Undo/Redo

Let's build a text editor that supports undo/redo for text insertion and deletion operations.

Step 1: Define the Command Interface

public interface Command {
void execute();
void undo();
}

Step 2: Implement the Receiver (The Text Document)

public class TextDocument {
private StringBuilder content;
private String name;
public TextDocument(String name) {
this.name = name;
this.content = new StringBuilder();
}
public void insert(int position, String text) {
if (position < 0 || position > content.length()) {
throw new IllegalArgumentException("Invalid position");
}
content.insert(position, text);
System.out.println("Inserted '" + text + "' at position " + position);
}
public void delete(int position, int length) {
if (position < 0 || position + length > content.length()) {
throw new IllegalArgumentException("Invalid delete range");
}
String deletedText = content.substring(position, position + length);
content.delete(position, position + length);
System.out.println("Deleted '" + deletedText + "' from position " + position);
}
public String getContent() {
return content.toString();
}
public void display() {
System.out.println("Document '" + name + "': " + content.toString());
}
}

Step 3: Implement Concrete Commands

Insert Command:

public class InsertCommand implements Command {
private TextDocument document;
private int position;
private String text;
private boolean executed;
public InsertCommand(TextDocument document, int position, String text) {
this.document = document;
this.position = position;
this.text = text;
this.executed = false;
}
@Override
public void execute() {
document.insert(position, text);
executed = true;
}
@Override
public void undo() {
if (executed) {
// To undo an insert, we delete the inserted text
document.delete(position, text.length());
executed = false;
}
}
}

Delete Command:

public class DeleteCommand implements Command {
private TextDocument document;
private int position;
private String deletedText;
private boolean executed;
public DeleteCommand(TextDocument document, int position, int length) {
this.document = document;
this.position = position;
this.executed = false;
// Store the text that will be deleted for potential undo
this.deletedText = document.getContent().substring(position, position + length);
}
@Override
public void execute() {
document.delete(position, deletedText.length());
executed = true;
}
@Override
public void undo() {
if (executed) {
// To undo a delete, we insert the deleted text back
document.insert(position, deletedText);
executed = false;
}
}
}

Step 4: Implement the Invoker (Command History)

import java.util.Stack;
public class CommandHistory {
private Stack<Command> undoStack;
private Stack<Command> redoStack;
public CommandHistory() {
this.undoStack = new Stack<>();
this.redoStack = new Stack<>();
}
public void executeCommand(Command command) {
command.execute();
undoStack.push(command);
redoStack.clear(); // Clear redo stack when new command is executed
System.out.println("Command executed. Undo stack size: " + undoStack.size());
}
public void undo() {
if (!undoStack.isEmpty()) {
Command command = undoStack.pop();
command.undo();
redoStack.push(command);
System.out.println("Undo performed. Undo stack: " + undoStack.size() + 
", Redo stack: " + redoStack.size());
} else {
System.out.println("Nothing to undo");
}
}
public void redo() {
if (!redoStack.isEmpty()) {
Command command = redoStack.pop();
command.execute();
undoStack.push(command);
System.out.println("Redo performed. Undo stack: " + undoStack.size() + 
", Redo stack: " + redoStack.size());
} else {
System.out.println("Nothing to redo");
}
}
public void clearHistory() {
undoStack.clear();
redoStack.clear();
System.out.println("Command history cleared");
}
}

Step 5: Complete Example with Client Code

public class TextEditor {
private TextDocument document;
private CommandHistory history;
public TextEditor(String documentName) {
this.document = new TextDocument(documentName);
this.history = new CommandHistory();
}
public void insertText(int position, String text) {
Command command = new InsertCommand(document, position, text);
history.executeCommand(command);
}
public void deleteText(int position, int length) {
Command command = new DeleteCommand(document, position, length);
history.executeCommand(command);
}
public void undo() {
history.undo();
}
public void redo() {
history.redo();
}
public void displayDocument() {
document.display();
}
// Demo usage
public static void main(String[] args) {
TextEditor editor = new TextEditor("My Document");
System.out.println("=== Initial State ===");
editor.displayDocument();
System.out.println("\n=== Performing Operations ===");
editor.insertText(0, "Hello");
editor.displayDocument();
editor.insertText(5, " World");
editor.displayDocument();
editor.deleteText(5, 6); // Delete " World"
editor.displayDocument();
System.out.println("\n=== Undo Operations ===");
editor.undo(); // Undo delete
editor.displayDocument();
editor.undo(); // Undo second insert
editor.displayDocument();
editor.undo(); // Undo first insert
editor.displayDocument();
System.out.println("\n=== Redo Operations ===");
editor.redo(); // Redo first insert
editor.displayDocument();
editor.redo(); // Redo second insert  
editor.displayDocument();
System.out.println("\n=== Mixed Operations ===");
editor.insertText(11, "!");
editor.displayDocument();
editor.undo(); // Can still undo after redo
editor.displayDocument();
}
}

Expected Output:

=== Initial State ===
Document 'My Document': 
=== Performing Operations ===
Inserted 'Hello' at position 0
Command executed. Undo stack size: 1
Document 'My Document': Hello
Inserted ' World' at position 5
Command executed. Undo stack size: 2
Document 'My Document': Hello World
Deleted ' World' from position 5
Command executed. Undo stack size: 3
Document 'My Document': Hello
=== Undo Operations ===
Undo performed. Undo stack: 2, Redo stack: 1
Document 'My Document': Hello World
Undo performed. Undo stack: 1, Redo stack: 2
Document 'My Document': Hello
Undo performed. Undo stack: 0, Redo stack: 3
Document 'My Document': 
=== Redo Operations ===
Redo performed. Undo stack: 1, Redo stack: 2
Document 'My Document': Hello
Redo performed. Undo stack: 2, Redo stack: 1
Document 'My Document': Hello World
=== Mixed Operations ===
Inserted '!' at position 11
Command executed. Undo stack size: 3
Document 'My Document': Hello World!
Undo performed. Undo stack: 2, Redo stack: 1
Document 'My Document': Hello World

Key Design Considerations

1. Storing State for Undo

  • For Insert: Store the position and text inserted. Undo by deleting that text.
  • For Delete: Store the position and the text that was deleted. Undo by inserting that text back.

2. Memory Management

For applications with potentially large undo histories:

  • Implement a maximum history size
  • Use weak references
  • Consider storing command deltas instead of full states
  • Implement checkpointing for very large documents

3. Composite Commands (Macro Commands)

You can group multiple commands together:

public class MacroCommand implements Command {
private List<Command> commands;
public MacroCommand() {
this.commands = new ArrayList<>();
}
public void addCommand(Command command) {
commands.add(command);
}
@Override
public void execute() {
for (Command command : commands) {
command.execute();
}
}
@Override
public void undo() {
// Undo in reverse order
for (int i = commands.size() - 1; i >= 0; i--) {
commands.get(i).undo();
}
}
}

4. Handling Complex Dependencies

For commands that depend on each other:

  • Store sufficient context in each command
  • Validate undo/redo conditions
  • Consider using the Memento pattern for complex object state snapshots

Advantages of This Approach

  1. Separation of Concerns: The invoker doesn't need to know about the concrete commands or the receiver.
  2. Extensibility: Easy to add new command types without modifying existing code.
  3. Flexible History Management: Can implement different history strategies (limited size, persistent storage, etc.).
  4. Support for Macros: Can easily compose multiple commands into single undoable operations.

Limitations and Considerations

  • Memory Usage: Storing command history consumes memory.
  • Complexity: Simple applications might not need this level of sophistication.
  • Persistence: Saving/loading command history for session persistence requires additional work.

Conclusion

The Command Pattern provides an elegant and robust foundation for implementing undo/redo functionality in Java applications. By encapsulating operations as objects and maintaining them in stacks, we can easily reverse and replay user actions. This approach not only solves the immediate problem of undo/redo but also leads to more maintainable, testable, and extensible code.

The pattern's flexibility allows for sophisticated features like macro commands, transactional operations, and even scripting capabilities, making it invaluable for any application that requires comprehensive action history management.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper