A limitation with yesterday’s error-preserving approach is that it leaves you on your own to recover from problems. Assuming your error definitions are sufficiently granular, this should be straightforward but tedious. Find out what went wrong, recover from it, then replay everything that happened afterwards.
Recovering from failures automatically is difficult in general, after all, if we could do that there wouldn’t have been a failure in the first place. But there’s no reason to then have a bunch of code that replays the rest of the workflow from the point of recovery, you’ve already written that code on the happy path.
Why can’t we just do that? On an error, why don’t we recover from the immediate error then go back in time to the point where it occurred and start again with our recovered value? Seems straightforward enough, let’s do it. In doing so, let’s borrow (at least in a superficial fashion) the idea of the optional object.
A Maybe
object represents the possibility that a value exists, and the ability to find out whether it does. Should it not contain a value, you should be able to find out why not.
@interface Maybe : NSObject
@property (nonatomic, strong, readonly) NSError *error;
- (BOOL)hasValue;
- recoverWithStartingValue:value;
+ just:value;
+ none:aClass error:(NSError *)anError;
@end
I have a value
OK, let’s say that what you wanted to do succeeded, and you want to use the result object. It’d be kindof sucky if you had to test whether the Maybe
contained a value at every step of a long operation, and unwrap it to get the object out that you care about. So let’s not make you do that.
@interface Just : Maybe
-initWithValue:value;
@end
@implementation Just
{
id _value;
}
-initWithValue:value {
self = [super init];
if (self) {
_value = value;
}
return self;
}
-(NSError *)error {
NSAssert(NO, @"No error in success case");
return nil;
}
-(BOOL)hasValue { return YES; }
-recoverWithStartingValue:value {
NSAssert(NO, @"Cannot recover from success");
return nil;
}
-forwardingTargetForSelector:(SEL)aSelector { return _value; }
@end
OK, if everything succeeds you can use the Maybe
result (which will be Just
the value) as if it is the value itself.
I don’t have a value
The other case is that your operation failed, so we need to represent that. We need to know what type of object you don’t have(!), which will be useful because we can then treat the lack of value as if it is an instance of the value. How will this work? In None
, the no-value version of a Maybe
, we’ll just absorb every message you send.
@interface None : Maybe
-initWithClass:aClass error:(NSError *)anError;
@end
@implementation None
{
Class _class;
NSError *_error;
NSMutableArray *_invocations;
}
-initWithClass:aClass error:(NSError *)anError {
self = [super init];
if (self) {
_class = aClass;
_error = anError;
_invocations = [NSMutableArray array];
}
return self;
}
-(NSError *)error { return _error; }
-(BOOL)hasValue { return NO; }
-methodSignatureForSelector:(SEL)aSelector {
return [_class instanceMethodSignatureForSelector:aSelector];
}
-(void)forwardInvocation:(NSInvocation *)anInvocation {
id returnValue = self;
[_invocations addObject:anInvocation];
[anInvocation setReturnValue:&returnValue];
}
-recoverWithStartingValue:value {
id nextObject = value;
while([_invocations count]) {
id invocation = [_invocations firstObject];
[_invocations removeObjectAtIndex:0];
[invocation invokeWithTarget:nextObject];
[invocation getReturnValue:&nextObject];
}
return nextObject;
}
@end
Again, there’s no need to unwrap a None
(and that makes no sense, because it doesn’t contain anything). You just use it as if it is the thing that it represents (a lack of).
Recovering
You go through your complex process, and somewhere along the way it failed. At this point your Maybe
answer turned into None
, because you didn’t get a value at some step. But it carried on paying attention to what you wanted to do.
Now it’s time to turn back the clock. Looking at the error I get from the None
, I can see what step failed and what I need to do to be in a position to try again. When I make that happen, I’ll get some object which would’ve been valid at that intermediate point in my operation.
Because the None
was paying attention to what I tried to do to it, I can replay the whole process from the point where it failed, using the result from my recovery path.
Worked Example
Here’s an object that requires two steps to use. Either step could fail, and one of them does unless you take action to fix it up.
@interface AnObject : NSObject
@property (nonatomic, assign, getter=isRecovered) BOOL recovered;
-this;
-that;
@end
@implementation AnObject
-this
{
return [self isRecovered] ? [Maybe just:self] :
[Maybe none:[self class] error:[NSError errorWithDomain:@"Nope"
code:23
userInfo:@{}]];
}
-that
{
return [Maybe just:@"Winning"];
}
@end
In using this object, I want to compose the two steps. If it goes wrong, I know what to do to recover, but I don’t want to have to explicitly write out the workflow twice if I got it right the first time.
int main(int argc, char *argv[]) {
@autoreleasepool {
id anObject = [AnObject new];
id result = [[anObject this] that];
if ([result hasValue]) {
NSLog(@"success: %@", [result lowercaseString]);
} else {
NSLog(@"failed with error %@, recovering...", [result error]);
[anObject setRecovered:YES];
result = [result recoverWithStartingValue:anObject];
NSLog(@"ended up with %@", [result uppercaseString]);
}
}
}
Conclusion
The NSError
-star-star convention lets you compose possibly-failing messages and find out either that it succeeded, or where it went wrong. But it doesn’t encapsulate what would have happened had it gone right, so you can’t just rewind time to where things failed and try again. It is possible to do so, simply by encapsulating the idea that something might work…maybe.
Pingback: Michael Tsai - Blog - Maybe, Just, and None in Objective-C
Common Lisp’s conditions system?
Seems intriguing, but can you go a bit more into the mechanics of what’s happening during recovery?
Seems like you’re somehow using the cocoa invocation semantics into a replayable command pattern, but I’d love to hear more about that and to verify what exactly is happening there.
@Slava: my inspiration is the same papers by Goodenough that describe LISP’s error recovery system, I haven’t used Common Lisp enough to make a good judgement but I’d be unsurprised to find similarities.
@Michael: what’s supposed to happen in the recovery case is that the None instance takes the replacement object you supplied, and goes into replaying the invocations with that object as the first receiver. You’re right, it’s a Command pattern, a bit like NSUndoManager.
However, there’s a problem, which I first noticed a few hours after posting but I was up in the air and couldn’t do anything about it :(. Because I give the type of the intended receiver, only messages that can be received by that type are accepted when recording the chain of retry invocations. In general most methods are likely to be transformations that return a value of a different type: as an example you might fetch some values from the UI, create an object from them, then apply some transformation to it like serialisation. The approach presented above won’t support that.
There are two ways out of this. I could keep the type information in the None class, but for every call I’d have to work out what type the receiver is to construct a new None instance. That’s basically a non-starter though, as the type information retained at runtime isn’t rich enough to recover what class or protocol a returned object is supposed to be, you can only find out that it’s an object.
The practical way to proceed, which introduces constraints I’m comfortable with and other people may not be, is to assume that the return value and all parameter types are objects. It means you can’t use primitives, C structures or C++ classes in parameters or return types when building a recoverable method chain: I’m happy with that because I try to keep such things to the boundaries of my systems anyway.