0

I was thinking to store all file operations inside an array, along with the reverse operations which are used for undos.

Example:

[

  [
     'op' => 'move',
     'parameters' => [$path_from, $path_to],
     'undo' => [
        'op' => 'move',
        'parameters' => [$path_to, $path_from],
     ]
  ],



  [
     'op' => 'create_folder',
     'parameters' => [$path],
     'undo' => [
        'op' => 'remove',
        'parameters' => [$path],
     ]
  ],    

  .........


]

This is a list of file operations that were made by the user. The 'op' value corresponds to a function and 'parameters' are the arguments passed to the function.

When user requests to undo everything until the first op, the "undos" are executed.

Will this work? Are there better options? Are there some PHP support classes that I can use to write less code?

3

This will mostly work. In object-oriented programming, this idea is known as the Command Pattern. We define an object that knows how to perform some operation in one step. We also extend that object with a procedure to undo that step. This allows us to implement undo/redo functionality as commonly used in text processors.

The difficulty is that some operations can be difficult to undo at all, or that the undo operation works except for a few minor aspects. The Design Patterns book likens this problem to hysteresis: an action and the reverse action diverge.

E.g. a move operation is quite easy to undo, as you noticed. However, the undo operation might not correctly restore timestamps, access controls, or other metadata of the file. In many cases that would be totally irrelevant, in other use cases this could be a deal breaker. It is therefore useful to think about the exact requirements. E.g. when the Git source control system tracks file versions, it records whether a file is set as executable, but mostly discards the user/group/all file permission bits.

Other operations are destructive, and cannot be undone except by saving the whole previous state. E.g. a command “in a file foo.txt, replace every occurrence of Fred with George” is easy to implement, but the reverse operation is not “replace every occurrence of George with Fred”. Instead, we have to keep track of every substitution location (possibly difficult to implement, unless you use a tool like diff), or have to backup the file in order to offer undo (might use lots of memory). For example, you'd have to save the whole file to provide the reverse of a remove operation.

For these reasons, it is useful to limit the undo buffer (e.g. to 50 entries), and to not implement undo where there are no sensible semantics. In such cases where the user would expect undo but there is none, it would be important to warn the user about this problem.

2

In short, yes this will work. The longer answer begins by saying depends.

The Command Pattern

What you have described is the command pattern; this is quite a large topic but it summarised as follows here: http://c2.com/cgi/wiki?CommandPattern

Encapsulate a request as an object, thereby letting you parameterize clients with different requests, queue or log requests, and support undoable operations.

In simple terms, every action taken by the user is stored along with instructions of how to undo that action. Just as you have described in your question.

Object Orientated

For the purposes of efficiency, I would suggest an object oriented approach rather than a simple data array. The data array will rapidly become unmanageable as the number of supported operations increases; an object oriented approach will provide a much more suitable code-base. The main reason being that you have a clear single point of responsibility when defining how an operation is to be done and undone.

All operations are defined as classes that implement a common interface; e.g:

interface Operation
{
    public function do();
    public function undo();
}

Each of the 'operation' objects contain all of the properties and methods required to prepare and perform the operation. You then have a 'unit-of-work' object that tracks the operations performed by the user. This is simply a matter of storing the operation objects in a container.

You can then undo actions by instructing the 'unit-of-work' to undo the last operation. The 'unit-of-work' class might look something like:

class UnitOfWork
{
    private $history;
    private $pos;

    public function __construct()
    {
        $this->history = [];
        $this->pos = false;
    }

    public function do(Operation $op)
    {
        // Try to perform the operation, handle errors as you go
        try {
            $op->do();
        } catch(Exception $e) {
            throw new RuntimeException('Unknown error when executing the operation; error: '.$e->getMessage());
        }

        // If successful, discard any previously undone actions and add the new action to the array
        if ($this->pos === false) {
            $this->history = [$op];
            $this->pos = 0;
        } else {
            $this->history = array_slice($this->history, 0, ($this->pos+1));
            $this->history[] = $op;
            $this->pos++;
        }
    }

    public function undo()
    {
        // Try to undo the last action, handle errors as you go
        if ($this->pos != false) {
            try {
                $this->history[$this->pos]->undo();
            } catch(Exception $e) {
                throw new RuntimeException('Unknown error when undoing the operation; error: '.$e->getMessage());
            }
        } else {
            throw new UnderflowException('There are no operations to undo');
        }

        // if successful, move back one place in the history
        if ($this->pos <= 0) {
            $this->pos = false;
        } else {
            $this->pos--;
        }
    }

    public function redo()
    {
        // Try to redo the next action, handle errors as you go
        if (count($this->history) > ($this->pos+1) || count($this->history) > 0 && ($this->pos===false)) {
            try {
                $this->history[$this->pos+1]->do();
            } catch(Exception $e) {
                throw new RuntimeException('Unknown error when redoing the operation; error: '.$e->getMessage());
            }
        } else {
            throw new UnderflowException('There are no operations to redo');
        }

        // if successful, move forward one place in the history
        if ($this->pos === false) {
            $this->pos = 0;
        } else {
            $this->pos++;
        }
    }
}

If you would like a worked example of the command pattern, then you can find one here: https://gist.github.com/cangelis/1442951

Complications

Some operations do not have a clear reversal. This is usually because information required to undo the original action is destroyed in the process of performing the act. The only way to get round this is to explicitly record the information that you will later need to undo the action.

@amon has already provided a description of this in his answer so read his response for more detail of the potential complications.

  • 1
    I like your approach, although the instanceof check is a bit unnecessary, you could just specify the object type in the parameter of the function. – Andy Jan 29 '16 at 8:32
  • @DavidPacker Fair comment, code sample updated. I also added support for a 'redo action' method (the 'future' operations are forgotten when a new operation is executed). – Marvin Jan 29 '16 at 10:12

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.