3

I'm developing a Command Pattern with Undo/Redo support. The environment is Unity C# but should be mostly language-agnostic.

My initial commands comprise of adding and removing objects to and from a space, but I'm encountering commands that lose their reference to their object. Removing an object means destroying it, so if I:

add an object
remove an object
undo

then the first command that adds the object will have its own internal reference to the object nullified since the Remove command destroys the original and creates a new one. I've done some digging and have found this described in this blog post but no solutions were posted.

I have some ideas on how to solve this but would like some input.

  1. Set up some callbacks so that commands register themselves with an object, and can be notified if the reference changes. This might be overengineering.
  2. Don't destroy the object on removal, just deactivate/hide them. This seems to be an easier way to achieve it, but I'm worried about memory usage in the app since the objects will have meshes attached to them.
  3. Set up a registry of some sort that, given the correct ID, the commands can query for the correct object that they should be referencing. This definitely seems overkill.

I probably shouldn't optimise early, but the domain for the application is pretty strict. Are these good ways of solving the problem? What else can I try?

1
  • 1
    Option 2 is the way to go: the remove command merely detaches the object from the document hierarchy, but as it is still in the undo stack, the object itself does not get destroyed. For that you need a generic reference count mechanism. About your concern: yes, Undo stack takes up memory. Limit nr of undo steps if that is an issue. Commented Jun 14, 2021 at 11:25

3 Answers 3

1

A variation of option 2 could use a Decoractor or a Proxy that has:

  • a destroy() method that destroys the object it wraps
  • an inject(o) method to inject a new object, to be called by undo or redo
  • an isDestroyed() method to inspect whether it's been destroyed or not
  • commands whould have a reference to the wrapper/decorator/proxy object which is not really destroyed, util the command itself is destroyed because it's too old in the undo history
  • such wrapper/decorator/proxy will be lightweight when "empty" (in the blog post you linked, there was the problem of objects taking up too much ram to keep then stashed around the undo history)
1
  • Ah this is really cool, but we'd still have to update the proxy object that the inner/wrapped object has been destroyed right? The isDestroyed() is a great outcome, but if it returns true then the proxy object will still lose its reference to the wrapped object. It would have to know that it's been destroyed, search for an object that has the same properties and then use that as its internal reference. Or use a callback so that it can update on destroy, but that's now bleeding into option 1. Am I understanding this correctly? Commented Jun 11, 2021 at 1:57
1

I have done this recently in Javascript - but I think this could still help you.

This is the undo.js module - basically four methods - done to record the custom undo/redo callbacks, undo and redo to al them and reset to remove all undos/redos (e.g. after loading a new file):

let undo_stack = []
let redo_stack = []

export function done(undo_fn, redo_fn, _log = null) {
  // clear redo_stack
  if (redo_stack.length > 0) {
    redo_stack = []
  }
  undo_stack.push({undo:undo_fn, redo:redo_fn, log:_log})
if (_log) { console.log(_log)}
}

export function undo() {
  if (undo_stack.length > 0) {
    let command = undo_stack.pop()
    command.undo()
    redo_stack.push(command)
if (command.log) { console.log("undo: "+command.log)}
  }
}

export function redo()  {
  if (redo_stack.length > 0) {
    let command = redo_stack.pop()
    command.redo()
    undo_stack.push(command)
if (command.log) { console.log("redo: "+command.log)}
  }
}

export function reset() {
  redo_stack = []
  undo_stack = []
}

Here is an example of undo being used where handleSelect is called when selecting a drop down - which means storing the old and new index of the selected dropdowns and then using them by callbacks through undo/redo.

function handleSelect(event) {
  event.preventDefault()
  let select = event.target
  let old_index = select.dataset.quandoLastIndex
  let new_index = select.selectedIndex
  select.dataset.quandoLastIndex = new_index
  toggleRelativesOnElement(select)
  let _undo = () => {
    select.selectedIndex =  old_index;
    toggleRelativesOnElement(select)
  }
  let _redo = () => {
    select.selectedIndex =  new_index;
    toggleRelativesOnElement(select)
  }
  undo.done(_undo, _redo, "Change Selected")
  return false
}

I haven't shown the binding to undo/redo buttons - but basically it just calls undo.js undo or redo...

I've added a delete example - which in my case removes a block - this also needs to keep a reference to the original block (object) for undo (and redo). This is bit more complex and has some specifics for my visual editor

function removeBlock(elem, parent_node, next_sibling) {
  let id = elem.dataset.quandoId
  let option_parents = []
  if (id) { // Store any references to this option - for undo
    option_parents = _get_parent_options(id)
    _populateLists()
  }
  elem.classList.remove("gu-hide") // To reveal the element if undone later
  let _undo = () => {
    parent_node.insertBefore(elem, next_sibling)
    _populateLists()
    _restore_options(id, option_parents)
  }
  let _redo = () => {
    parent_node.removeChild(elem)
    _populateLists()
  }
  undo.done(_undo, _redo, "Delete Block")
}

I hope this helps.

2
  • Hi thanks for your comment, that's a nice undo/redo implementation. For this question, I was asking about destroying heap objects and previous commands losing a reference to them. I assume the index you're storing is an integer, not an object, so I feel like we'd run into the same problem using this system right? Commented Jun 11, 2021 at 1:51
  • I've added a slightly more complex example that allows undo/redo of delete for my visual editor.
    – AndyS
    Commented Jun 12, 2021 at 8:15
0

After reading the answers and comments, I've gone with the simpler "hide, don't destroy" method of handling the removed objects. This works for my implementation, and the drawback of taking up memory is minimised by culling old commands.

The other approaches I suggested may work for other projects but it's over-engineering for my situation.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

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