In Objective-C, messaging a nil
reference is a no-op, so if we consider the following class:
@interface Person : NSObject
- (void)undoSomething;
@end
@implementation Person
- (void)undoSomething {
NSLog(@"Performing undo...");
}
@end
The following code snippet will not print anything, but it also won’t crash:
Person *steve = nil;
[steve undoSomething];
Since Objective-C is a dynamic language, we can represent message invocations with objects and invoke them at runtime. NSInvocation
makes that easy, and it also respects the convention that messaging a nil
object is a no-op:
Person *steve = nil;
NSMethodSignature *undoSignature = [Person instanceMethodSignatureForSelector:@selector(undoSomething)];
NSInvocation *undoCall = [NSInvocation invocationWithMethodSignature:undoSignature];
[undoCall setTarget:steve];
[undoCall setSelector:@selector(undoSomething)];
[undoCall invoke];
Again, the code snippet above won’t print anything, but it also won’t crash.
This also holds for weak references. If steve
were a weak reference that got nil
-ified before the invocation was dispatched, this code would fail gracefully. Here’s an example for completeness:
Person *steve = [[Person alloc] init];
Person *__weak steveWeak = steve;
steve = nil;
NSMethodSignature *undoSignature = [Person instanceMethodSignatureForSelector:@selector(undoSomething)];
NSInvocation *undoCall = [NSInvocation invocationWithMethodSignature:undoSignature];
[undoCall setTarget:steveWeak];
[undoCall setSelector:@selector(undoSomething)];
[undoCall invoke];
The code above also doesn’t print anything, since steveWeak
gets nil
-ified when steve
is assigned to nil
.
Now onto NSUndoManager
.
If you’re not familiar with NSUndoManager
, it is the primary class used when implementing undo support in Cocoa and Cocoa Touch apps. Every time you perform an action that could be un-done, you register it with an instance of NSUndoManager
. The “action” is simply a method (or block) that gets invoked when the user presses “Undo”. Here’s a basic example that registers our -undoSomething
method, which reverses whatever -doSomething:
does:
- (IBAction)doSomething:(id)sender {
Person *steve = ...;
[[self.window.undoManager prepareWithInvocationTarget:steve] undoSomething];
// Do something.
}
Actions are stored and invoked in LIFO (last-in first-out) order, so if we were to press “Undo” now, -[NSUndoManager undo]
would end up invoking -undoSomething
.
In fact, if you look at the stack trace at the time -undo
is invoked, you’ll notice NSUndoManager
internally simply keeps around a stack of NSInvocation
s to dispatch with each undo operation.
#1 0x00007fffca1d2369 in -[NSInvocation invokeWithTarget:] ()
#2 0x00007fffcbc73f25 in -[_NSUndoStack popAndInvoke] ()
#3 0x00007fffcbc73ccc in -[NSUndoManager undoNestedGroup] ()
If you take a look at the documentation for -prepareWithInvocationTarget:
, you’ll notice it claims the “undo manager maintains a weak reference to the target”. As I learned the hard way with some new crashes in Pixen, that is actually not true.
Consider the following example:
- (void)crashyUndo {
Person *steve = [[Person alloc] init];
[[self.window.undoManager prepareWithInvocationTarget:steve] undoSomething];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self.window.undoManager undo];
});
}
Since steve
falls out-of-scope before dispatch_after
calls -[NSUndoManager undo]
, its pointee gets deallocated. When the call to undo
is finally made, we get a crash, and if we turn on Zombie Objects in the Scheme Editor, we can confirm NSUndoManager
is trying to talk to a deallocated object:
-[Person retain]: message sent to deallocated instance 0x618000002350
This crash would not occur if NSUndoManager
kept around a weak reference to our Person
since it would have been nil
-ified, and as we convinced ourselves above, sending an NSInovcation
to a nil
target is perfectly safe. Thus, contrary to the documentation, NSUndoManager
does not actually store weak references to undo targets.