Subclassing NSArray

Where I talk about how one creates a proper NSArray subclass.


Creating custom collections is rarely necessary nowadays. Most of the time you can safely go with the collection classes provided by the standard library you are working with and not bother with the implementation details. What would be the reason to write a custom collection anyway?

I cannot come up with another reason to subclass Foundation collections right now, but to be honest, here at #justcodingthings we do not actually need a reason to do something. We do it because we can, and that is the only reason we need.

There is nothing new under the sun, and @mikeash has already discussed this topic in his Friday Q&A, but I still thought I’d reiterate it once more at least for the reason of providing another example of the technique.

To have a semi-realistic sample to work with, let’s build a dynamically mapped array and integrate it into NSArray cluster.

Define ‘dynamically mapped’

So suppose we want to perform a particular transform or mapping to the elements of a given NSArray. We could represent the mapping with a block:

id (^mapper)(id object, NSUInteger index) {
    // return transformed object
}

So each time we access array[i] we will actually receive the result of mapper(array[i], i). There are some questions open to discussion of course. What will we do if the block returns nil for some of the elements? To be consistent with NSArray API we should not return nil since we do not expect that from NSArrays. I suggest we transform nils to NSNull instances automatically.

What will the mapping interface look like? I suppose something like that will do nicely:

NSArray *originalArray = @[@1, @2, @3];
NSArray *mappedArray =
    [originalArray arrayByApplyingMapping: ^(id object, NSUInteger index){
        return @([object integerValue]*2);
    }];
XCTAssertEqualObjects(mappedArray[1], @4, @""); // @[@2, @4, @6]

The goal is to get a proper NSArray instance which does not invoke the mapper block at the point of creation, but keeps it and invokes it when we try to access a certain element1.

We’ll be creating a CCMappedArray2 with the following interface:

@interface CCMappedArray : NSArray

- (instancetype)initWithArray:(NSArray *)originalArray
        mapper:(id (^)(id object, NSUInteger index))block NS_DESIGNATED_INITIALIZER;

@end

And we’ll also need a category on NSArray:

@interface NSArray(CCMappedArray)

- (NSArray *)arrayByApplyingMapping:(id(^)(id object, NSUInteger index))mapper;

@end

Since we know we’ll be subclassing NSArray, let’s dive into the documentation and look what we’ll have to do to achieve our goal.

Any subclass of NSArray must override the primitive instance methods count and objectAtIndex:. These methods must operate on the backing store that you provide for the elements of the collection.

You might want to implement an initializer for your subclass that is suited to the backing store that the subclass is managing. If you do, your initializer must invoke one of the designated initializers of the NSArray class, either init or initWithObjects:count:. The NSArray class adopts the NSCopying, NSMutableCopying, and NSCoding protocols; if you want instances of your own custom subclass created from copying or coding, override the methods in these protocols.

So we’ll have to deal with the following:

Implementation

The category implementation is trivial:

@implementation NSArray(CCMappedArray)

- (NSArray *)arrayByApplyingMapping:(id(^)(id object, NSUInteger index))mapper
{
    return [[CCMappedArray alloc] initWithArray: self
                                         mapper: mapper];
}

@end

Although there is no reason to create a CCMappedArray without a mapper block, we still won’t check the parameter for nil value here and leave this to the CCMappedArray itself if we find it necessary.

Now let’s get to the interesting part. We’ll probably need to store the references to the original array and the mapper block in the CCMappedArray, so we add these properties in the class extension:

@interface CCMappedArray()
@property (nonatomic, strong, readonly) NSArray *originalArray;
@property (nonatomic, copy, readonly) id (^mapper)(id, NSUInteger);
@end

Now our designated initializer will look like this:

- (instancetype)initWithArray:(NSArray *)originalArray
                       mapper:(id (^)(id object, NSUInteger index))block
{
    self = [super init];

    if (self != nil) {
        _originalArray = [originalArray copy];
        _mapper = [block copy];
    }

    return self;
}

Note how we’re using [originalArray copy] there - for simplicity we want only to work with immutable arrays, although it is pretty simple to expand the implementation to work with mutable arrays too3.

We also need to override the designated initializers of the NSArray class:

- (instancetype)init
{
    return [self initWithArray: @[] mapper: nil];
}


- (instancetype)initWithObjects:(const id [])objects
                          count:(NSUInteger)count
{
    NSArray *array = [[NSArray alloc] initWithObjects: objects count: count];
    return [self initWithArray: array mapper: nil];
}

Note that both these initializers do not have a mapper block essentially making the returned CCMappedArray a useless wrapper over the original NSArray. It’s OK since initializing CCMappedArray using the standard NSArray initializers is not really intended.

Now let’s get to primitive methods of the NSArray. We don’t change the count of the original array4, so the count method just returns the original count:

- (NSUInteger)count
{
    return self.originalArray.count;
}


- (id)objectAtIndex:(NSUInteger)index
{
    id object = [self.originalArray objectAtIndex: index];
    if (self.mapper != nil) {
        object = self.mapper(object, index) ?: [NSNull null];
    }
    return object;
}

Without a mapper block, our array represents an identity mapping.

Let’s continue with NSCopying. Note that since we are working with immutable arrays only, we never have to actually copy anything:

- (instancetype)copyWithZone:(NSZone *)zone
{
    return self; // Immutable objects do not need to be copied
}

Then we implement NSMutableCopying and NSCoding as following:

#pragma mark - <NSMutableCopying>

- (id)mutableCopyWithZone:(NSZone *)zone
{
    // A neat trick which will automatically map every value
    // when creating the copy
    return [[NSMutableArray alloc] initWithArray: self];
}

#pragma mark - <NSCoding>

- (void)encodeWithCoder:(NSCoder *)aCoder
{
    NSArray *array = [[NSArray alloc] initWithArray: self];
    [array encodeWithCoder: aCoder];
}


- (instancetype)initWithCoder:(NSCoder *)aDecoder
{
    NSArray *array = [[NSArray alloc] initWithCoder: aDecoder];
    return [self initWithArray: array mapper: nil];
}

So that is actually it. Our CCMappedArray is ready and working. The code for this class can be found here. I’ve added some unit tests for CCMappedArray as well.

  1. We could also think of a lazy mapping and memoise the mapped value to return it immediately when trying to access the same element for the second time, but this is probably out of the scope of this post. 

  2. Prefix CC stands for ‘Containers Collection’ which is a name of a github project where mapped array is implemented as well as some other collection-like classes. 

  3. Although to keep the NSMutableArray contract intact we would need to be sure that setting elements of the array after it has been mapped would not apply the mapping block again. So we would need to store the indexes of the ‘automatic’ mapped elements and the indexes of the elements set explicitly by the user of the class and process these elements accordingly. 

  4. I’ve seen and worked with an implementation of NSArray mapping using a category on NSArray which applied the given block to the receiver immediately and returned the mapped NSArray. This implementation handled nil returned from the block differently, essentially removing elements mapped to nil from the result. This would change the count of the elements within the resulting array. With our current implementation, this would result in unnecessary complications so we replace nil results with NSNulls and keep the count intact. 


Project maintained by wanderwaltz Hosted on GitHub Pages — Based on theme by mattgraham