Defining custom key path operators

(This was prompted by a recent question on StackOverflow.)

Key-value coding is one of my more favorite things about Cocoa (up there with NSPredicate). Basically, it allows you to access properties of objects by name (ie, as a string) rather than invoking a method (er… sending a message) directly.

Along with accessing properties, you can (with some limitations) retrieve calculated values (not strictly properties) about these objects. For example:

NSArray * anArray = .... //an NSArray of NSNumbers 
NSNumber * count = [anArray valueForKeyPath:@"@count"];
NSNumber * max = [anArray valueForKeyPath:@"@max.self"];

There are several others, like @sum, @avg, and some collection-oriented operators. For a full list, see this page in the documentation.

But what if you want something else? What if you wanted to do something like @size, which we’ll define as the size (in bytes) that the object directly occupies in memory? To answer this question, let’s see if we can figure out what’s going on with NSArray, and take some clues from that.

#import <objc/runtime.h>

unsigned int methodCount = 0;
Method * methods = class_copyMethodList([NSArray class], &methodCount);
for (int i = 0; i < methodCount; ++i) {
  Method m = methods[i];
  NSLog(@"%@", NSStringFromSelector(method_getName(m)));
}
free(methods);

When we run this, we’re going to get a bunch of methods listed, but there are some interesting ones that should catch our eye:

_distinctUnionOfSetsForKeyPath:
_distinctUnionOfObjectsForKeyPath:
_distinctUnionOfArraysForKeyPath:
_unionOfSetsForKeyPath:
_unionOfArraysForKeyPath:
_unionOfObjectsForKeyPath:
_minForKeyPath:
_maxForKeyPath:
_countForKeyPath:
_avgForKeyPath:
_sumForKeyPath:

Those look familiar! Specifically, they look like our collection operators that have undergone a bit of string manipulation. It looks like the @ has been replaced with an _ and the string ForKeyPath: has been appended. (These methods also appear on NSSet)

So theoretically, if we just add a method that matches this format (_ + {nameOfKeyPath} + ForKeyPath:), it should work!

A bit more introspection reveals that these methods return an object, which makes sense because valueForKeyPath: also returns an object (id). We need to do the same.

Here we go (we’d need an identical category for NSSet):

#import <objc/runtime.h>

@interface NSArray (CustomKVCOperator)

- (id) _sizeForKeyPath:(NSString *)keyPath;

@end

@implementation NSArray (CustomKVCOperator)

- (id) _sizeForKeyPath:(NSString *)keyPath {
  id keyPathValue = [self valueForKeyPath:keyPath];
  size_t instanceSize = class_getInstanceSize([keyPathValue class]);
  return [NSNumber numberWithInt:instanceSize];
}

@end

Now we can test this:

NSArray * array = [NSArray array];
NSLog(@"size: %@", [array valueForKeyPath:@"@size.self"]); //logs "8"

It worked! (Honestly, the first time I tried this I was totally surprised that it did)


While this example is pretty contrived, it’s not difficult to imagine a scenario when it could be useful. Unfortunately, this only works on collections. You can’t (for example) add a _sqrtForKeyPath: method to NSNumber and hope to use @sqrt as a keyPath operator on NSNumber. Bummer. :(

As always with things like this, a word of caution: this works, but only because we rely on some undocumented behavior. This is by no means something that would get an app rejected from the App Stores, but be aware that Apple can change the implementation of valueForKeyPath: at any time.

A safer (albeit more complex) way to achieve this same functionality would be to subclass the appropriate collection (NSArray or NSSet) and override the valueForKeyPath: method. However, this introduces new problems, since both are class clusters, and subclassing a class cluster is fraught with peril.

At any rate, if you find yourself wishing that you had @stdDev or @productOfEveryThirdNumber operators, now you know.

  1. iosadam reblogged this from objectivesea
  2. hash3r reblogged this from funwithobjc
  3. objectivesea reblogged this from funwithobjc and added:
    get the maximum value in an NSSet full...using Key-Value-Coding like so: NSSet *set
  4. funwithobjc posted this
Short URL for this post: http://tmblr.co/Zt522y1R1U1k
blog comments powered by Disqus