Click here to Skip to main content
15,884,425 members
Articles / Programming Languages / C#
Article

Automatic Undo/Redo for .NET classes

Rate me:
Please Sign up or sign in to vote.
4.49/5 (17 votes)
29 Jul 2005CPOL3 min read 57.2K   601   58   12
A unique spin on implementing generic Undo/Redo funcionality for .NET classes using method invocation interception...

Introduction

Almost anyone who has operated a word processor has used undo/redo to some extent. This is the feature that lets you effectively roll back (undo) and roll forward (redo) any state changes (i.e. add words) you have made to the document while editing. This concept can be quite useful beyond text editors and documents.

The idea is to provide automatic Undo/Redo functionality for .NET classes. The functionality is implemented as an IMessageSink. This article will demonstrate how to intercept method invocations 'in-flight' before they reach the target, allowing for pre and post processing of method calls.

Design goals/requirements

Classes wanting automatic Undo/Redo functionality need to do nothing more than sub-class ContextBoundObject or MarshalByRefObject (in the MarshalByRefObject case, you must insure all calls must come through a TransparentProxy) and 'mark' the class with the custom attribute (UndoRedoAttribute) included in this article. One other small requirement is that the class must also implement IUndoRedo. Implementing is actually odd in this context because the class just needs to provide the methods, they can be empty as they are never called. An abstract base class is also provided that performs these steps for you (described later).

Design notes

This technique is built using undocumented features of the CLR, namely IContributeObjectSink. I don’t find this to be an issue; however a similar technique can be applied using a RealProxy approach (which is a documented class).

The RealProxy approach may not be quite as performant (MSFT), but clearly performance is not a consideration when MarshalByRefObject is sub-classed. Note: calls on MarhsalByRefObject can’t be inlined, plus calls on TransparentProxy objects force the call stack to be serialized. Reflection is also used, which isn’t known for its performance, but allows for some very powerful techniques.

The code can also be very easily modified to support Undo/Redo for field setter operations, but I’ll leave that to the reader.

The functionality is exposed through the IUndoRedo interface.

C#
public interface IUndoRedo
{
  void Undo();
  void Redo();
  void Flush();
}

How it works

The basic idea is to track object state via interception of property set operations. To do this code must be injected between the caller and the target. The CLR provides a great facility for doing this using custom attributes and implementing IContributeObjectSink. I will briefly explain how code injection works.

When an object that derives from ContextBoundObject is created the CLR will look for attributes (class level) that implement IContextAttribute. Here is the custom attribute implementation:

C#
internal sealed class UndoRedoAttribute : Attribute, 
                                     IContextAttribute
 {
  
  public void GetPropertiesForNewContext(
                         IConstructionCallMessage msg)
  {
   IContextProperty prop = new UndoRedoProperty();
   msg.ContextProperties.Add(prop);
  }

  public bool IsContextOK(Context ctx, 
                         IConstructionCallMessage msg)
  {
   if(ctx.GetProperty("UndoRedo Property") != null)
   {
    return true;
   }
   return false;
  }

 }

The CLR will first invoke IsContextOK. This allows the attribute to determine if the current Context is going to be appropriate (UndoRedoProperty is 'installed'). If it returns false, GetPropertiesForNewContext will be invoked, allowing the attribute to 'install' the UndoRedoProperty into the Context.

The UndoRedoProperty is responsible for injecting the interception class (a class that implements IMessageSink).

C#
public sealed class UndoRedoProperty : IContextProperty, 
                                      IContributeObjectSink
 {

  public IMessageSink GetObjectSink(MarshalByRefObject obj, 
                                      IMessageSink nextSink)
  {
   return new UndoRedoSink(obj, nextSink);
  }

  public string Name
  {
   get
   {
    return "UndoRedo Property";
   }
  }

  public bool IsNewContextOK(Context newCtx){return true;}
  public void Freeze(Context newContext){}
 }

The main thing to notice on the Context property is the implementation of IContributeObjectSink. The CLR will call GetObjectSink every time a new object is created that has the UndoRedoAttribute. Now all invocations will come through the custom message sink.

Now that we have all the boilerplate code out of the way let's look at the IMessageSink implementation:

C#
internal sealed class UndoRedoSink : IMessageSink
{

 private IMessageSink    _nextSink;  /*ref to next sink in chain*/
 private object          _target;    /*target of method invocations*/
 private Type            _targetType;/*type of target(cached for perf)*/
 private ArrayList       _actions;   /*set history*/
 private int             _index;     /*current index of set buffer*/
 private int             _cnt;       /*count of undo calls*/

 
 public IMessageSink NextSink
 {
  get
  {
   return _nextSink;
  }
 }

 
 private void Add(SetAction sa)
 {
  /*
   * store set operation and update current index...
   */
   if(_actions.Count <= _index)
   {
    _actions.Add(sa);
   }
   else
   {
    _actions[_index] = sa;
   }
  
   _index++;

  /*
   * clear 
   */
  for(int i = _index;i<_actions.Count;i++)
  {
   _actions[i] = null;
  }
 }

 private bool CanUndo()
 {
  return _index > 0 ;
 }

 private bool CanRedo()
 {
  return _cnt > 0 && _actions.Count >_index && 
                          _actions[_index] != null;
 }

 public IMessage SyncProcessMessage(IMessage msg)
 {

   IMethodCallMessage mcm = msg as IMethodCallMessage;
   IMethodReturnMessage mrm = null;

   /*
   * is the message a method call?...
   */
   if (mcm != null)
   {               
     /*
     * is the message a property setter?...
     */
     if (mcm.MethodName.StartsWith("set_"))
     {
       /*
       * grab property name...
       */
       string propertyName = mcm.MethodName.Substring(4);
                      
       /*
       * record the set operation...
       */
       SetAction action    = new SetAction();
       action.propertyName = propertyName;
       action.newVal  = mcm.InArgs[0];

       /*
       * capture old value...
       */
       PropertyInfo pi  = _targetType.GetProperty(propertyName,
                                   BindingFlags.Instance | 
                                BindingFlags.Public | 
                                BindingFlags.NonPublic);
       action.oldVal       = pi.GetValue(_target,new object[0]);
    
       /*
       * store...
       */
       Add(action);

     }

     /*
     * undo last action...
     */
     if (mcm.MethodName == "Undo")
     {
               
       if(CanUndo())
       {
          _cnt++;
          --_index;

          /*
          * set the state back to the prior value...
          */
          PropertyInfo pi = 
            _targetType.GetProperty(
              ((SetAction)_actions[_index]).propertyName, 
               BindingFlags.Instance |
               BindingFlags.NonPublic | 
               BindingFlags.Public);
          pi.SetValue(_target, 
             ((SetAction)_actions[_index]).oldVal,
             new object[0]);
       }
                
       /*
       * no need to forward on...
       */
       mrm = new ReturnMessage(null, mcm);
       return mrm;
     }

           

     /*
     * redo last action...
     */
     if (mcm.MethodName == "Redo")
     {
       if(CanRedo())
       {
         _cnt--;
         SetAction action = 
            (SetAction)_actions[_index];
         PropertyInfo pi = 
           _targetType.GetProperty(action.propertyName, 
            BindingFlags.Instance | BindingFlags.NonPublic | 
            BindingFlags.Public);
         pi.SetValue(_target, action.newVal, new object[0]);
         _index++;

       }
               
       mrm = new ReturnMessage(null, mcm);
       return mrm;
     }
   
   } 

   /*
   * are we being told to 'empty' the 
   * undo/redo history?
   */
   if (mcm.MethodName == "Flush")
   {
     _actions.Clear();
     _cnt = 0;
     _index = 0;

     /*
     * no need to forward to actual object,
     * we are all done...
     */
     mrm = new ReturnMessage(null, mcm);
     return mrm;
   }

   /*
   * forward to terminator sink...
   */
   return _nextSink.SyncProcessMessage(msg);
            
 }
 
 public IMessageCtrl AsyncProcessMessage(IMessage msg, 
                                IMessageSink replySink)
 {
   return _nextSink.AsyncProcessMessage(msg,replySink);
 }

 public UndoRedoSink(MarshalByRefObject target, 
                                 IMessageSink nextSink)
 {
        
   _target  = (UndoRedo)target;
   _targetType = _target.GetType();
   _nextSink   = nextSink;
   _actions = new ArrayList();
   _index  = 0;
 }
}

The sink handles four types of invocations:

  1. Property set calls
  2. Flush calls
  3. Undo calls
  4. Redo calls

All the rest of the invocations are simply forwarded onto the next sink.

Using the code

Using the sink couldn't be easier. As stated earlier, just implement IUndoRedo, sub-class ContextBoundObject and place the UndoRedoAttribute above your class. A convenience class is also provided, which does the required steps on your behalf:

C#
[UndoRedo]
public abstract class UndoRedo : ContextBoundObject, 
                                            IUndoRedo
{
 public void Undo() { }
 public void Redo() { }
 public void Flush() { }
 
}

Simply derive your class from UndoRedo and your ready to go.

Conclusion

Using this technique, other interesting services can also be implemented like:

  • Security
  • Just-In-Time Activation
  • Logging

Whether you find this sink useful or not, I hope you can see the power of method interception. The notion of message sinks provide new and interesting ways to reuse code.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Engineer Fortress Tech.
United States United States
This member has not yet provided a Biography. Assume it's interesting and varied, and probably something to do with programming.

Comments and Discussions

 
GeneralKeep Undoing Pin
Caldas6-May-09 8:24
Caldas6-May-09 8:24 
QuestionInterception of nested calls? Pin
Danny van Kasteel19-Mar-06 23:55
Danny van Kasteel19-Mar-06 23:55 
Hi,

Great article, very usable sample code as well!

However, I've stumbled into a rather disturbing problem using CBO-based interception. Since this type of interception relies on context-switches to allow the sink to be activated, nested calls to property setters are not caught, and the system could fail.

For instance, if I have a property A, which internally sets property B via its public accessor (e.g. Me.B = value), this action is not intercepted since the call executes in the same context as the originally intercepted call to the property setter of A.

Although this could be made the caller's problem and explained away as a design consideration, I currently find myself in the position where I need to be 100% certain whether a property has been set or not...

Is there any way that you know that will allow attribute-, CBO-based interception to intercept these nested calls?

TIA,

Danny van Kasteel




QuestionDoesn't work with redefined properties ? Pin
yletourneau29-Sep-05 11:45
yletourneau29-Sep-05 11:45 
QuestionNice idea, but a nightmare to debug ? Pin
yletourneau27-Sep-05 2:47
yletourneau27-Sep-05 2:47 
AnswerRe: Nice idea, but a nightmare to debug ? Pin
Matt Davison20-Oct-05 13:55
Matt Davison20-Oct-05 13:55 
AnswerRe: Nice idea, but a nightmare to debug ? Pin
Carl Warman30-Sep-10 9:59
Carl Warman30-Sep-10 9:59 
GeneralSome Thoughts Pin
Kent Boogaart11-Aug-05 17:33
Kent Boogaart11-Aug-05 17:33 
GeneralRe: Some Thoughts Pin
Matt Davison14-Aug-05 4:52
Matt Davison14-Aug-05 4:52 
QuestionWhy IUndoRedo interface if the method name is intercepted? Pin
Jim Wiese (aka Spunk)3-Aug-05 5:16
Jim Wiese (aka Spunk)3-Aug-05 5:16 
AnswerRe: Why IUndoRedo interface if the method name is intercepted? Pin
Matt Davison3-Aug-05 16:25
Matt Davison3-Aug-05 16:25 
Generalimpressive article Pin
mathewww1-Aug-05 12:53
mathewww1-Aug-05 12:53 
GeneralRe: impressive article Pin
Matt Davison1-Aug-05 14:32
Matt Davison1-Aug-05 14:32 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.