Search code examples
objective-cxcodensmutablesetnsrunningapplication

In a macOS Objective-C application, I have subclassed NSMutableSet for enforcing an equality different from isEqual. Is my implementation fine?


In my macOS Objective-C application, I have created a subclass of NSMutableSet. What I want to achieve is a NSMutableSet that does not use isEqual: as the comparing strategy. Specifically, The set will contain objects of type NSRunningApplication, and I want the set to work based on the equality of the objects bundle identifiers. Following is my implementation:

Header file:

#import <Cocoa/Cocoa.h>

NS_ASSUME_NONNULL_BEGIN

@interface BundleIdentifierAwareMutableSet : NSMutableSet 

@property (atomic, strong) NSMutableSet     *backStorageMutableSet;
@property (atomic, strong) NSMutableArray     *backStorageMutableArray;

@end

NS_ASSUME_NONNULL_END

Implementation file:

#import "BundleIdentifierAwareMutableSet.h"

@implementation BundleIdentifierAwareMutableSet

@synthesize backStorageMutableSet;

- (instancetype)init {
    
    self = [super init];
    
    if (self) {
        self.backStorageMutableSet = [[NSMutableSet alloc] init];
        self.backStorageMutableArray = [[NSMutableArray alloc] init];

    }
    
    return self;
}

- (NSUInteger)count {
    return [self.backStorageMutableArray count];
}

- (NSRunningApplication *)member:(NSRunningApplication *)object {
    
    __block NSRunningApplication *returnValue = nil;
    
    [self.backStorageMutableArray enumerateObjectsUsingBlock:^(NSRunningApplication * _Nonnull app, NSUInteger __unused idx, BOOL * _Nonnull stop) {
        
        if ([app.bundleIdentifier isEqualToString:[object bundleIdentifier]]) {
            returnValue = app;
            if (![app isEqual:object]) {
                NSLog(@"An ordinary set would have not considered the two objects equal.");
            }
            *stop = YES;
        }
        
    }];
    
    return returnValue;
    
}

- (NSEnumerator *)objectEnumerator {
    
    self.backStorageMutableSet = [NSMutableSet setWithArray:self.backStorageMutableArray];
    
    return [self.backStorageMutableSet objectEnumerator];
    
}

- (void)addObject:(NSRunningApplication *)object {
    
    NSRunningApplication *app = [self member:object];
    
    if (app == nil) {
        [self.backStorageMutableArray addObject:object];
    }
}



- (void)removeObject:(NSRunningApplication *)object {
    
    NSArray *snapShot = [self.backStorageMutableArray copy];
    
    [snapShot enumerateObjectsUsingBlock:^(NSRunningApplication * _Nonnull currentApp, NSUInteger __unused idx, BOOL * _Nonnull __unused stop) {
        
        if ([[currentApp bundleIdentifier] isEqualToString:[object bundleIdentifier]]) {
            [self.backStorageMutableArray removeObject:currentApp];
            if (![currentApp isEqual:object]) {
                NSLog(@"An ordinary set would have not considered the two objects equal.");
            }
        }
        
    }];
    
}

This seems to work, and indeed, When applicable, Xcode logs that an ordinary NSMutableSet would have not considered two members equal. I would like to bring this implementation to the Production App, but I am afraid I have not considered something important, since this is the first time I subclass NSMutableSet. For example, I am worried about the following method:

- (NSEnumerator *)objectEnumerator {
    
    self.backStorageMutableSet = [NSMutableSet setWithArray:self.backStorageMutableArray];
    
    return [self.backStorageMutableSet objectEnumerator];
    
}

This is the only use I do of the backStorageMutableSet since the rest is backed to the array. Is this fine or can bring troubles ? Will other parts of the subclass bring problems ? Any help will be greatly appreciated. Thanks


Solution

  • Don't do this. Subclassing collections should be the last resort. It can have implications on performance, ... Try to use highest possible abstraction and go down if it doesn't work for you for some reason.

    Wrapper object

    Wrap the NSRunningApplication in another object and provide your own hash & isEqual: methods.

    Application.h:

    @interface Application: NSObject
    
    @property (nonatomic, strong, readonly, nonnull) NSRunningApplication *application;
    
    @end
    

    Application.m:

    @interface Application ()
    
    @property (nonatomic, strong, nonnull) NSRunningApplication *application;
    
    @end
    
    @implementation Application
    
    - (nonnull instancetype)initWithRunningApplication:(NSRunningApplication *_Nonnull)application {
        if ((self = [super init]) == nil) {
            // https://developer.apple.com/documentation/objectivec/nsobject/1418641-init?language=objc
            //
            // The init() method defined in the NSObject class does no initialization; it simply
            // returns self. In terms of nullability, callers can assume that the NSObject
            // implementation of init() does not return nil.
            return nil;
        }
        
        self.application = application;
        return self;
    }
    
    // https://developer.apple.com/documentation/objectivec/1418956-nsobject/1418795-isequal?language=objc
    - (BOOL)isEqual:(id)object {
        if (![object isKindOfClass:[Application class]]) {
            return NO;
        }
        
        Application *app = (Application *)object;
        return [self.application.bundleIdentifier isEqualToString:app.application.bundleIdentifier];
    }
    
    // https://developer.apple.com/documentation/objectivec/1418956-nsobject/1418859-hash?language=objc
    - (NSUInteger)hash {
        return self.application.bundleIdentifier.hash;
    }
    @end
    

    Toll-free bridging & CFMutableSetRef

    CFSet is bridged with the NSSet, CFMutableSet is bridged with the NSMutableSet, etc. It means that you can create a set via Core Foundation API and then use it as NSSet for example. Core Foundation is a powerful framework which exposes more stuff to you.

    You can provide a custom set of callbacks for the CFSet.

    /*!
        @typedef CFSetCallBacks
        Structure containing the callbacks of a CFSet.
        @field version The version number of the structure type being passed
            in as a parameter to the CFSet creation functions. This
            structure is version 0.
        @field retain The callback used to add a retain for the set on
            values as they are put into the set. This callback returns
            the value to store in the set, which is usually the value
            parameter passed to this callback, but may be a different
            value if a different value should be stored in the set.
            The set's allocator is passed as the first argument.
        @field release The callback used to remove a retain previously added
            for the set from values as they are removed from the
            set. The set's allocator is passed as the first
            argument.
        @field copyDescription The callback used to create a descriptive
            string representation of each value in the set. This is
            used by the CFCopyDescription() function.
        @field equal The callback used to compare values in the set for
            equality for some operations.
        @field hash The callback used to compare values in the set for
            uniqueness for some operations.
    */
    typedef struct {
        CFIndex             version;
        CFSetRetainCallBack         retain;
        CFSetReleaseCallBack        release;
        CFSetCopyDescriptionCallBack    copyDescription;
        CFSetEqualCallBack          equal;
        CFSetHashCallBack           hash;
    } CFSetCallBacks;
    

    There're predefined sets of callbacks like:

    /*!
        @constant kCFTypeSetCallBacks
        Predefined CFSetCallBacks structure containing a set of callbacks
        appropriate for use when the values in a CFSet are all CFTypes.
    */
    CF_EXPORT
    const CFSetCallBacks kCFTypeSetCallBacks;
    

    Which means that you're not forced to provide all of them, but you're free to modify just some of them. Let's prepare two callback functions:

    // typedef CFHashCode    (*CFSetHashCallBack)(const void *value);
    CFHashCode runningApplicationBundleIdentifierHash(const void *value) {
        NSRunningApplication *application = (__bridge NSRunningApplication *)value;
        return [application.bundleIdentifier hash];
    }
    
    // typedef Boolean        (*CFSetEqualCallBack)(const void *value1, const void *value2);
    Boolean runningApplicationBundleIdentifierEqual(const void *value1, const void *value2) {
        NSRunningApplication *application1 = (__bridge NSRunningApplication *)value1;
        NSRunningApplication *application2 = (__bridge NSRunningApplication *)value2;
        return [application1.bundleIdentifier isEqualToString:application2.bundleIdentifier];
    }
    

    You can use them in this way:

    - (NSMutableSet<NSRunningApplication *> *_Nullable)bundleIdentifierAwareMutableSetWithCapacity:(NSUInteger)capacity {
        // > Predefined CFSetCallBacks structure containing a set of callbacks
        // > appropriate for use when the values in a CFSet are all CFTypes.
        //
        // Which means that you shouldn't bother about retain, release, ... callbacks,
        // they're already set.
        //
        // CFSetCallbacks can be on stack, because this structure is copied in the
        // CFSetCreateMutable function.
        CFSetCallBacks callbacks = kCFTypeSetCallBacks;
        
        // Overwrite just the hash & equal callbacks
        callbacks.hash = runningApplicationBundleIdentifierHash;
        callbacks.equal = runningApplicationBundleIdentifierEqual;
        
        // Try to create a mutable set.
        CFMutableSetRef set = CFSetCreateMutable(kCFAllocatorDefault, capacity, &callbacks);
        
        if (set == NULL) {
            // Failed, do some error handling or just return nil
            return nil;
        }
        
        // Transfer the ownership to the Obj-C & ARC => no need to call CFRelease
        return (__bridge_transfer NSMutableSet *)set;
    }
    

    &

    NSMutableSet<NSRunningApplication *> *set = [self bundleIdentifierAwareMutableSetWithCapacity:50];
    [set addObjectsFromArray:[[NSWorkspace sharedWorkspace] runningApplications]];
    NSLog(@"%@", set);