Tuesday, December 6, 2011

Objective-C Tuesdays: more NSArray sorting

Welcome to another Objective-C Tuesdays. Last week, we looked at sorting C arrays and NSArrays. Today, we will continue looking at sorting NSArrays using NSSortDescriptors.

As we saw last week, the sorting methods of NSArray require you to specify a comparator in one form or another. When sorting an NSArray of simple objects like NSStrings or NSDates, the comparators are usually pretty simple to write and common objects often have useful comparator methods like -caseInsensitiveCompare: and -localizedCompare:.

When sorting NSArrays of more complex objects, writing comparators is often more tedious and error-prone. Here's the interface for simple Person class:
// Person.h
@interface Person : NSObject

@property (strong) Address *address;
@property (strong) NSDate *birthdate;
@property (copy) NSString *firstName;
@property (copy) NSString *lastName;

@end

And here's the Address class used by Person:
// Address.h
@interface Address : NSObject

@property (copy, nonatomic) NSString *street;
@property (copy, nonatomic) NSString *city;
@property (copy, nonatomic) NSString *state;
@property (copy, nonatomic) NSString *country;
@property (copy, nonatomic) NSString *postalCode;

@end

If we have an NSArray of Person objects, we may want to sort them in country, lastName, firstName order. Here's one way to do that, using a comparator block:
// sort Person objects by lastName, firstName
Person *frodo = [Person new];
[frodo setFirstName:@"Frodo"];
[frodo setLastName:@"Baggins"];
// ...
[[frodo address] setCountry:@"Shire"];

Person *bilbo = [Person new];
[bilbo setFirstName:@"Bilbo"];
[bilbo setLastName:@"Baggins"];
// ...
[[bilbo address] setCountry:@"Shire"];

Person *legolas = [Person new];
[legolas setFirstName:@"Legolas"];
[legolas setLastName:@"Greenleaf"];
// ...
[[legolas address] setCountry:@"Mirkwood"];

NSArray *people = [NSArray arrayWithObjects:frodo, bilbo, legolas, nil];
NSArray *sortedPeople = [people sortedArrayUsingComparator:^(id item1, id item2) {
Person *person1 = item1;
Person *person2 = item2;

// NSComparisonResult is a typedef for int
NSComparisonResult result = [[[person1 address] country] compare:[[person2 address] lastName]];
if (result) {
return result;
}

result = [[person1 lastName] compare:[person2 lastName]];
if (result) {
return result;
}

result = [[person1 firstName] compare:[person2 firstName]];
if (result) {
return result;
}

return NSOrderedSame; // NSOrderedSame == 0
}];
// sortedPeople contains:
// Legolas Greenleaf (Mirkwood)
// Bilbo Baggins (Shire)
// Frodo Baggins (Shire)

The general pattern of a multi-field comparator is simple: check each field in turn, stop and return the comparison result if non-zero; if all fields are equal, return zero (or NSOrderedSame to be more descriptive). This quickly becomes tedious when you have many fields to sort by or you need to dig down into child or grandchild objects for fields.

Fortunately, there's an easier way to do this. NSArray has a method called -sortedArrayUsingDescriptors: that takes an array of NSSortDescriptor objects. Each NSSortDescriptor specifies a key path and sort direction (ascending or descending). The order of NSSortDescriptors in the array determines the precedence of each field. If you're not familiar with Key Value Coding (KVC), you may not have encountered key paths before. KVC is similar reflection in Java and other dynamic languages. KVC allows you to get and set fields on an object using the field names as strings, called keys. To access fields on child objects, you use keys separated by dots to form a key path; KVC knows how to drill down your object graph and access fields on child objects. There are a lot of interesting things you can do with KVC, but today we will stick to building an array of NSSortDescriptors:
NSSortDescriptor *byCountry = [NSSortDescriptor sortDescriptorWithKey:@"address.country" 
ascending:YES];
NSSortDescriptor *byLastName = [NSSortDescriptor sortDescriptorWithKey:@"lastName"
ascending:YES];
NSSortDescriptor *byFirstName = [NSSortDescriptor sortDescriptorWithKey:@"firstName"
ascending:YES];
NSArray *sortDescriptors = [NSArray arrayWithObjects:byCountry, byLastName, byFirstName, nil];

Notice that the byCountry sort descriptor uses the key path @"address.country": it will first get the value of the address property of the Person object, then get the country property of the address. Key paths can be as deep as your object graph.

Using the array of sort descriptors is easy:
NSArray *sortedPeople = [people sortedArrayUsingDescriptors:sortDescriptors];
// sortedPeople contains:
// Legolas Greenleaf (Mirkwood)
// Bilbo Baggins (Shire)
// Frodo Baggins (Shire)

This certainly makes creating complex sort criteria much easier, and you're not limited to the default comparator for a field. You can specify a selector for a comparator method on the field this way:
// specify a method to call on the lastName object
NSSortDescriptor *byLastName = [NSSortDescriptor sortDescriptorWithKey:@"lastName"
ascending:YES
selector:@selector(caseInsensitiveCompare:)];

Or for more specialized comparisons, you can pass in a NSComparator block this way:
// sort descriptor using length of last name
NSSortDescriptor *byLastNameLength = [NSSortDescriptor sortDescriptorWithKey:@"lastName"
ascending:YES
comparator:^(id item1, id item2) {
NSString *lastName1 = item1;
NSString *lastName2 = item2;
// cast result to NSComparisonResult so that the
// compiler infers the correct return type
return (NSComparisonResult) ([lastName1 length] - [lastName2 length]);
}];

Specifying complex sort orders with NSSortDescriptors is the type of higher level, declarative code that is easy to write, easy to read and easy to maintain, and in most cases you should consider using NSSortDescriptor rather than writing your own comparator methods, functions or blocks.

Next time, we will look at sorting NSMutableArrays in place, rather than producing a sorted copy like the various -sortedArray methods.

No comments: