I recently fixed a rather pernicious and pervasive crashing bug in an app I’ve been contributing to. The bug appears to have resulted from a misunderstanding of some relatively subtle behavior in Objective-C. This post explores the problem as well as a solution.
Here is the problematic code:
@implementation NSString (Additions)
- (BOOL)isEmpty {
if (self == nil)
return YES;
if (self == (NSString*)[NSNull null])
return YES;
if ([self isEqualToString:@""])
return YES;
if (self.length == 0)
return YES;
return NO;
}
@end
Can you spot the problems? There are several. I will address them in order of increasing severity.
Whitespace
The least worrisome potential problem is the third if statement. This may even be correct in many circumstances, although it is redundant with the fourth if statement in these circumstances. In such circumstances the redundancy can be overlooked without any detrimental impact on behavior of the app.
It is incorrect, however, any time a string that only contains whitespace should be treated as empty. In those circumstances stringByTrimmingCharactersInSet:
should be used with the domain appropriate character set before checking for a length of 0. It should not be necessary to call isEqualToString:
under any circumstance.
NSNull
The second if statement is somewhat more troublesome. It is superfluous. A category method on NSString
will never be called on an instance of NSNull
. Code that cannot be called has no reason to exist. Yet the fact that it does exist and can never be correct should raise a red flag. It is a sign that the author may not understand how the Objective-C language works.
My best guess is that the author expected this code to be invoked when NSNull
is returned from a collection containing instances of NSString
. In reality, if a collection returns an instance of NSNull
and an attempt is made to call isEmpty
on that instance the app will crash due to an unrecognized selector (in the absence of an isEmpty
category method on NSObject
or NSNull
). The app needs to be audited to ensure it is correctly checking for NSNull
where necessary and is not relying on the NSString
category method to do that.
One way to address this problem would be to simply add a category to NSNull
with the appropriate isEmpty
method like this:
@implementation NSNull (Additions)
- (BOOL)isEmpty {
return YES;
}
@end
This method will allow us to safely call isEmpty
on objects returned from a collection that contains strings and uses NSNull
used as a nil
placeholder.
nil
Unfortunately, in the course of fixing the problem of NSNull
we have propagated the more subtle and most severe issue demonstrated in the first if statement of the original category method. This line of code is also superfluous and also indicates that the author may not understand some of the more subtle aspects of Objective-C very well.
The issue with this line of code is that it performs a nil-check on self. Since it is perfectly legal to send a message to nil
in Objective-C it is not totally unreasonable to think a method might be called when self is nil
. If that happened this if statement would be correct.
In reality, when you send a message to nil
the message send is short-circuited and nil
, NO
, 0
, or a struct where members are initialized with these values is returned. No message send actually happens. In fact, no message send can happen as nil
pointers do not actually point to an object with an isa
Class
pointer. Without an isa
pointer the Objective-C runtime does not know which implementation of the selector to use.
When the isEmpty
message is sent to a nil
NSString
pointer NO
will automatically be returned. This is bad. The original code was expecting YES
to be returned when isEmpty
is sent to nil
.
Because NO
is returned when isEmpty
is sent to nil
, nil
values were slipping past it. The failed nil-check resulted in various issues including several crashes. In a real-world sized code base, this issue was made even more subtle by the fact that in most places, isEmpty
was preceded by a manual nil-check, therefore avoiding the problematic isEmpty
method.
What we have just discovered is that the intuitive semantics of isEmpty
do not play well with the nil
messaging behavior of Objective-C.1 The purpose of the method is to indicate whether an object has meaningful content or not. We are getting tripped up because isEmpty
does this by checking for the absence of content.
The Solution
The solution is rather simple. We check for presence of content rather than absence of content. If we happen to send a message to nil
, NO
will be returned just as one would expect. The check for presence of content does not have a semantic conflict with the behavior of nil-messaging.
This method can be named either hasContent
or isNotEmpty
. It is also possible to implement both (each name reads more clearly than the other in some calling contexts). I recommend implementing hasContent
as the primary method. It avoids the mental gymnastics of double-negatives that can result with isNotEmpty
(i.e. ![aString isNotEmpty]
). If you find you have call sites that read substantially more clearly when isNotEmpty
is used you can add that alias.
The simple version (assuming any characters are meaningful) looks like this:
@implementation NSString (Additions)
- (BOOL)hasContent {
return self.length > 0;
}
@end
If we want to ignore whitespace, the method would change to look like this:
@implementation NSString (Additions)
- (BOOL)hasContent {
NSString *trimmed =
[self stringByTrimmingCharactersInSet:
[NSCharacterSet whitespaceAndNewlineCharacterSet]
];
return trimmed.length > 0;
}
@end
Now we can add the negative variant:
@implementation NSString (Additions)
- (BOOL)isNotEmpty {
return [self hasContent];
}
@end
I will leave it as an exercise for the reader to create similar categories for NSNull
, NSArray
, NSDictionary
, and any other classes you may wish.
- If the semantics of nil-messaging are not clear to you, please refer to the section Working with Objects in Apple’s guide Programming with Objective-C.