Background
Part of what I do is software salvage.
I’ve had the joy of working on bringing life back to abandoned codebases, extend them with new features, modernize services and translate software from obsolete programming languages, operating systems and frameworks.
A frequent type of software modernization has risen with the slow tilt of the dinner table from Objective-C to Swift. Swift can now do everything Objective-C did, and more. In both cases, if you need to write something critically fast and closer to the hardware, you’ll do those bits in C or C++, and Swift still provides a way for bridging that gap. Meanwhile, Objective-C is no longer being updated or maintained by Apple.
All of the Apple-provided frameworks are accessible from both.
This makes translation from one to the other extremely easy. Theoretically, you could take each line of Objective-C and write a corresponding line in Swift, and call it a day. But as stated in Transference of Complexity this would mean a larger total sum of complexity, since Swift is a higher-level language.
But what happens when the a developer drops a mistake in logic and unwittingly works around it, resulting in accidentally correct, yet completely undocumented behavior?
(non-) array (non-) subscript
Problem: A feature from the original application, translated directly from a language to another using the same frameworks, behaves completely differently.
The original source code has an array lookup, using a running index from a for loop. This alone seems like a bad idea, but the original logic depends on it.
After directly translating the code, using the same input data:
- Original application: runs fine, the result from the array is correct
- Swift translation: Fatal error: Index out of range
Adding a check not to pick a value in either case would sound perfectly sane, but the original logic doesn’t have that. Also, the rest of the code depends on getting a value from this lookup. So figuring this out first would be necessary.
Reading through the logic these two situations quickly become apparent:
- The array might not have any elements
- The index may be larger than the length of the array
The documentation for NSArray
for Objective-C provides an important fact: accessing an index that doesn’t exist will throw an exception. So surely the index must exist when running the original application.
All these assumptions go out the window, when running this in debug quickly unveils a third fact:
- The array doesn’t exist
What’s happening?
The following is an oversimplified example of the actual situation in an Objective-C codebase. This code runs, compiles, finishes without any errors:
#import <Foundation/Foundation.h>
int main(int argc, const char * argv[]) {
NSArray *list;
NSLog(@"%d", (int)[list[47] integerValue]);
// Output: 0
return 0;
}
One might say: but why, that’s obviously wrong – there is no 48th element in the array! It’s not even initialized!
Looking again at the NSArray documentation, specifically the one for Objective-C, it does specify what should happen. The subscript shortcut is defined as being the same as calling objectAtIndex, and objectAtIndex states the following:
If
NSArray documentationindex
is beyond the end of the array (that is, ifindex
is greater than or equal to the value returned bycount
), anNSRangeException
is raised.
But, by design, all NSObject
s (such as NSArray
) can also be expressed as id
. Let’s see what happens if we slightly modify our test:
id list = nil;
NSLog(@"%d", (int)[list[47] integerValue]);
// Output: 0
- Since the array object isn’t really “there”, the dynamic method call goes just to an empty shell of an
NSObject
. - The subscript operator [] is mapped to objectAtIndexedSubscript.
- Since there’s no object, there’s no subscriptable elements.
- The returned object is (null).
So, now we have a null object, an empty id
just like the supposed non-list we began with! What really happens then is this:
id value = nil;
NSLog(@"%d", (int)[value integerValue]);
// Output: 0
The (null) NSObject responds to the integerValue! You can even write this as:
NSLog(@"%d", (int)[(id)nil integerValue]);
// Output: 0
However, if you do:
NSObject *value = nil;
NSLog(@"%d", (int)[value integerValue]);
// Won't compile: No visible @interface for 'NSObject' declares the selector 'integerValue'
The underlying mechanism is:
id
is a special kind of a variable, it’s a pointer to an instance of a class.- It contains one variable:
isa
which is of type Class. - This
isa
pointer is supposed to point to an instance of a class. - When the
id
is assigned as nil (or unassigned altogether), this contained pointer is just a null pointer, 0x0 – the same kind of a (null) that gets returned by the call to an undefined subscript method.
When you call integerValue on a null id
, it still compiles, because potentially it could contain either an NSNumber
or NSString
, both of which are known to the compiler because of the imported Foundation.h
at the very top.
Let’s try the same as at the beginning, but without the Foundation.h (or NSArray
or NSLog
for that matter, since they’re declared through it):
int main(int argc, const char * argv[]) {
id list;
return (int)[list[47] integerValue];
// Error: No known instance method for selector 'objectAtIndexedSubscript:'
// Error: No known instance method for selector 'integerValue'
}
But what happens if we add a dummy interface at the top that declares these selectors, never to be used?
@interface dummy
-(id) objectAtIndexedSubscript:(int) index;
-(int) integerValue;
@end
int main(int argc, const char * argv[]) {
id list;
return [list[47] integerValue];
}
// Returns: 0
Solution: No array? Zero. Index overflow? Zero.
In this particular case, the immediate fix was easy, after writing out the logic just to do sanity checks and using zero as a default value.
Conclusion
Sometimes the logic is hidden in what fails. When going from a language with no Optional comprehensions to one that does, use the null coalescing operator cautiously. This situation was first caused, then masked, in the new implementation by having the array created in a function that always returns a valid array object. While that is fine, in this case returning an optional and the occasional nil
would have dropped the problem into the right spot where it would have been quicker to catch.
As an additional lesson, don’t say “It’s fine” if it really isn’t. Objective-C has some really interesting quirks, not least being the ability to be completely happy to compile a call to a non-existent instance method for an object even if it’s not even remotely compatible. That, combined with the return type of id
for that imaginary method, can be a setup for a really nice cascading failure.
If you’ve ran into anything similar, please feel free to drop your experiences in comments below.
Leave a Reply