Introduction
An earlier post looked at 5 techniques for implementing undo/redo.Here I want to take another look the problem and look at a solution based on immutable data structures.
Essentially it is a check-pointing solution, but by using an immutable data structure there is essentially a zero time cost to making a checkpoint.
Nodes, paths, and trees
The running example from the earlier post used a tree as the data structure with the following interface.
public interface TreeI {
NodeI makeNode( String text ) ;
NodeI getRoot() ;
void insert( NodeI parent, int posn, NodeI child ) ;
void remove( NodeI parent, int posn ) ;
}
Our immutable version will be a bit different. We start with nodes. We want to have nodes that can't change. Our node class only has constructors and accessors, not mutators.
public class Node {
public Node ( String text, List<Node> children ) { ... }
int getChildCount() { ... }
Node getChild( int i) { ... }
String getLabel() { ... }
}
We can identify any node in a tree by its path from the root. For example, [] represents the root of a tree, [0] represents the first child of the root, [0,1] represents the second child of the first child of the root, and so on. For this scheme to make sense, the tree can never be empty; there will always be a root node. Now we change the tree interface to
public interface TreeI {
public Node getRoot( ) ;
public void replace( Path p, int i, int j, List<Node> newNodes );
public void changeLabel( Path p, String newLabel) ;
.
.
.
}
The replace function replaces all children of the given node from position i up to (but not including) position j with a list of new nodes. The changeLabel method changes the label on a node. Both these functions work by creating new nodes as needed. For example replace( p, 0, 1, ns ) where p is the list [0,1] will build three new nodes, all the other nodes of the new tree (apart from possibly the ones in list ns) will be shared with the old tree.
The interface is implemented by a class
public class Tree implements TreeI {
protected Node root = new Node( "", new ArrayList<Node>() ) ;
public Node getRoot( ) { return root }
public void replace( Path p, int i, int j, List<Node> newNodes ){ ... }
public void changeLabel( Path p, String newLabel) { ... }
.
.
.
}
Implementing undo and redo.
Once we have an immutable data structure undo and redo are very simple. The main difference compared with the checkpointing solution of the earlier post, is that we don't make copies of the model.public class TreeWithCheckpointing extends Tree {
Stack<Node> olderTrees() = new Stack<Node>() ;
Stack<Node> newerTrees() = new Stack<Node>() ;
public void checkpoint() {
olderTrees.push( root ) ;
newerTrees.clear() ; }
public void undo() {
if( olderTrees.size() > 0 ) {
newerTrees.push( root ) ;
root = olderTrees.pop() ; } }
public void redo() {
if( newerTrees.size() > 0 ) {
olderTrees.push( root ) ;
root = newerTrees.pop() ; } }
}
Merits
- This approach is very simple and fool proof. We don't need that the model be copyable, as we only copy pointers.
- There may other advantages to using immutable data structures. For the system where I used this, the main reason for using immutable data structures was to enforce invariants. If there are no mutators, we only have to worry about object invariants at node construction time. Another advantage is in reusing view object; we can make a cache mapping nodes to view objects, since the nodes never change, the view objects are sure to be accurate representations..
- Because there is no copying, it is more efficient than the checkpointing solution with mutable data.
Demerits
- The main disadvantage is the awkwardness of an immutable data structure. It works well for trees, but for an arbitrary graph we have to give up on using ordinary pointers to represent edges.
No comments:
Post a Comment