Creating a category for classes that implement a s

2019-01-23 08:38发布

问题:

Short problem description

Can I extend UIView with a category, but have it only work on subclasses that implement a specific protocol (WritableView)?

I.e. can I do something like the following?

@interface UIView<WritableView> (foo) // SYNTAX ERROR
- (void)setTextToDomainOfUrl:(NSString *)text;
- (void)setTextToIntegerValue:(NSInteger)value;
- (void)setCapitalizedText:(NSString *)text;
@end
@implementation UIView<WritableView> (foo)
// implementation of 3 above methods would go here
@end

Detailed problem description

Imagine I want the following category function added to any instance of UILabel:

[label setTextToDomainOfUrl:@"http://google.com"];

Which simply sets a UILabel's text property to google.com.

Simlarly, I want to be able to call this function on several other classes:

[button setTextToDomainOfUrl:@"http://apple.com"]; // same as: [button setTitle:@"apple.com" forState:UIControlStateNormal];
[textField setTextToDomainOfUrl:@"http://example.com"]; // same as: textField.text = @"example.com"
[tableViewCell setTextToDomainOfUrl:@"http://stackoverflow.com"]; // same as: tableViewCell.textLabel.text = @"stackoverflow.com"

Let's say I'm really happy with my design so far, and I want to add 2 more methods to all 4 classes:

[label setTextToIntegerValue:5] // same as: label.text = [NSString stringWithFormat:@"%d", 5];
[textField setCapitalizedText:@"abc"] // same as: textField.text = [@"abc" capitalizedString]

So now we have 4 classes that have 3 methods each. If I wanted to actually make this work, I would need to write 12 functions (4*3). As I add more functions, I need to implement them on each of my subclasses, which can quickly become very hard to maintain.

Instead, I want to implement these methods only once, and simply expose a new category method on the supported components called writeText:. This way instead of having to implement 12 functions, I can cut the number down to 4 (one for each supported component) + 3 (one for each method available) for a total of 7 methods that need to be implemented.

Note: These are silly methods, used just for illustrative purposes. The important part is that there are many methods (in this case 3), which shouldn't have their code duplicated.

My first step at trying to implement this is noticing that the first common ancestor of these 4 classes is UIView. Therefore, the logical place to put the 3 methods seems to be in a category on UIView:

@interface UIView (foo)
- (void)setTextToDomainOfUrl:(NSString *)text;
- (void)setTextToIntegerValue:(NSInteger)value;
- (void)setCapitalizedText:(NSString *)text;
@end

@implementation UIView (foo)
- (void)setTextToDomainOfUrl:(NSString *)text {
    text = [text stringByReplacingOccurrencesOfString:@"http://" withString:@""]; // just an example, obviously this can be improved
    // ... implement more code to strip everything else out of the string
    NSAssert([self conformsToProtocol:@protocol(WritableView)], @"Must conform to protocol");
    [(id<WritableView>)self writeText:text];
}
- (void)setTextToIntegerValue:(NSInteger)value {
    NSAssert([self conformsToProtocol:@protocol(WritableView)], @"Must conform to protocol");
    [(id<WritableView>)self writeText:[NSString stringWithFormat:@"%d", value]];
}
- (void)setCapitalizedText:(NSString *)text {
    NSAssert([self conformsToProtocol:@protocol(WritableView)], @"Must conform to protocol");
    [(id<WritableView>)self writeText:[text capitalizedString]];
}
@end    

These 3 methods will work as long as the current instance of UIView conforms to the WritableView protocol. So I extend my 4 supported classes with the following code:

@protocol WritableView <NSObject>
- (void)writeText:(NSString *)text;
@end

@interface UILabel (foo)<WritableView>
@end

@implementation UILabel (foo)
- (void)writeText:(NSString *)text {
    self.text = text;
}
@end

@interface UIButton (foo)<WritableView>
@end

@implementation UIButton (foo)
- (void)writeText:(NSString *)text {
    [self setTitle:text forState:UIControlStateNormal];
}
@end

// similar code for UITextField and UITableViewCell omitted

And now when I call the following:

[label setTextToDomainOfUrl:@"http://apple.com"];
[tableViewCell setCapitalizedText:@"hello"];

It works! Hazzah! Everything works perfectly... until I try this:

[slider setTextToDomainOfUrl:@"http://apple.com"];

The code compiles (since UISlider inherits from UIView), but fails at run time (since UISlider doesn't conform to the WritableView protocol).

What I would really like to do is make these 3 methods only available to those UIViews which have a writeText: method implemented (I.e. those UIViews which implement the WritableView protocol I set up). Ideally, I would define my category on UIView like the following:

@interface UIView<WritableView> (foo) // SYNTAX ERROR
- (void)setTextToDomainOfUrl:(NSString *)text;
- (void)setTextToIntegerValue:(NSInteger)value;
- (void)setCapitalizedText:(NSString *)text;
@end

The idea is that if this were valid syntax, it would make [slider setTextToDomainOfUrl:@"http://apple.com"] fail at compile time (since UISlider never implements the WritableView protocol), but it would make all my other examples succeed.

So my question is: is there any way to extend a class with a category, but limit it to only those subclasses that have implemented a specific protocol?


I realize I could change the assertion (which checks that it conforms to the protocol) to an if statement, but that would still allow the buggy UISlider line to compile. True, it won't cause an exception at runtime, but it won't cause anything to happen either, which is another kind of error I am also trying to avoid.

Similar questions that haven't been given satisfactory answers:

  • Category for a class that conforms to a protocol: This question seems to be asking the same thing, but didn't give specific examples, so it seems like it was misunderstood to mean something else.
  • Defining categories for protocols in Objective-C?: Different example provided, so the accepted answer says to implement it in the category of each class that needs the method (i.e. you would end up with 12 methods in my example); which is a totally fine answer for that one, but not really a good solution for this problem.
  • How do I define a category that adds methods to classes which implement a particular protocol?: The asker chose to go the route of checking for implementation of the protocol (and doing nothing if it doesn't conform), but what if you want it to spot errors at compile time?

回答1:

It sounds like what you're after is a mixin: define a series of methods that form the behaviour that you want, and then add that behaviour to only the set of classes that need it.

Here is a strategy I've used to great success in my project EnumeratorKit, which adds Ruby-style block enumeration methods to built-in Cocoa collection classes (in particular EKEnumerable.h and EKEnumerable.m:

  1. Define a protocol that describes the behaviour you want. For method implementations you are going to provide, declare them as @optional.

    @protocol WritableView <NSObject>
    
    - (void)writeText:(NSString *)text;
    
    @optional
    - (void)setTextToDomainOfUrl:(NSString *)text;
    - (void)setTextToIntegerValue:(NSInteger)value;
    - (void)setCapitalizedText:(NSString *)text;
    
    @end
    
  2. Create a class that conforms to that protocol, and implements all the optional methods:

    @interface WritableView : NSObject <WritableView>
    
    @end
    
    @implementation WritableView
    
    - (void)writeText:(NSString *)text
    {
        NSAssert(@"expected -writeText: to be implemented by %@", [self class]);
    }
    
    - (void)setTextToDomainOfUrl:(NSString *)text
    {
        // implementation will call [self writeText:text]
    }
    
    - (void)setTextToIntegerValue:(NSInteger)value
    {
        // implementation will call [self writeText:text]
    }
    
    - (void)setCapitalizedText:(NSString *)text
    {
        // implementation will call [self writeText:text]
    }
    
    @end
    
  3. Create a category on NSObject that can add these methods to any other class at runtime (note that this code doesn't support class methods, only instance methods):

    #import <objc/runtime.h>
    
    @interface NSObject (IncludeWritableView)
    + (void)includeWritableView;
    @end
    
    @implementation
    
    + (void)includeWritableView
    {
        unsigned int methodCount;
        Method *methods = class_copyMethodList([WritableView class], &methodCount);
    
        for (int i = 0; i < methodCount; i++) {
            SEL name = method_getName(methods[i]);
            IMP imp = method_getImplementation(methods[i]);
            const char *types = method_getTypeEncoding(methods[i]);
    
            class_addMethod([self class], name, imp, types);
        }
    
        free(methods);
    }
    
    @end
    

Now in the class where you want to include this behaviour (for example, UILabel):

  1. Adopt the WritableView protocol
  2. Implement the required writeText: instance method
  3. Add this to the top of your implementation:

    @interface UILabel (WritableView) <WritableView>
    
    @end
    
    @implementation UILabel (WritableView)
    
    + (void)load
    {
        [self includeWritableView];
    }
    
    // implementation specific to UILabel
    - (void)writeText:(NSString *)text
    {
        self.text = text;
    }
    
    @end
    

Hope this helps. I've found it a really effective way to implement cross-cutting concerns without having to copy & paste code between multiple categories.



回答2:

Swift 2.0 introduces Protocol Extensions which is exactly what I was looking for. Had I been just using Swift, I would have been able to achieve the desired results with the following code:

protocol WritableView {
    func writeText(text: String)
}

extension WritableView {
    func setTextToDomainOfUrl(text: String) {
        let t = text.stringByReplacingOccurrencesOfString("http://", withString:"") // just an example, obviously this can be improved
        writeText(t)
    }

    func setTextToIntegerValue(value: Int) {
        writeText("\(value)")
    }

    func setCapitalizedText(text: String) {
        writeText(text.capitalizedString)
    }
}

extension UILabel: WritableView {
    func writeText(text: String) {
        self.text = text
    }
}

extension UIButton: WritableView {
    fun writeText(text: String) {
        setTitle(text, forState:.Normal)
    }
}

Unfortunately, in my limited tests with Swift and Objective-C, it looks like you cannot use Swift protocol extensions in Objective-C (e.g. the moment I choose to extend the protocol WritableView in Swift, the protocol WritableView is no longer visible to Objective-C).