Potential bug with subclass / init

This compiles fine (no errors, no warnings) but crashes at runtime. Perhaps I am not supposed to do this (why?), but in this case the question is, is this a bug that compiler allows me doing this?

import Foundation

class MyString: NSMutableAttributedString {
    init(str: String) {
        super.init(string: str)
        // *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[MyString initWithString:]: unrecognized selector sent to instance 0x10181a090'
    }
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

func test() {
    _ = MyString(str: "Hello, World")
}

test()

I'm not certain, but there's some evidence that NSAttributedString and NSMutableAttributedString are class clusters, not concrete classes. This is an Obj-C concept that applies to classes like NSArray, NSDictionary and NSString. For some documentation, see here (after the highlighted note) and here (3rd sentence of the overview).

For class clusters, subclasses can't call up to super.init, because they aren't (or shouldn't be) instances of the super class. I'm not even sure it's possible to create classes in an Obj-C class cluster using Swift, because of Swift's initializer delegation rules.

Also note the warning in the -[NSAttributedString init] documentation: "The returned object might be different than the original receiver." I'd expect that Swift can handle this, but it's possible that it doesn't.

Also note that it's common for these basic Cocoa classes to return a placeholder object from the class alloc method, then return a private subclass of the cluster class from the init method.

Lots to beware here. :slight_smile:

4 Likes

@QuinceyMorris is right on the money here in that NSAttributedString and NSMutableAttributedString are class clusters β€” from the NSAttributedString docs overview:

The cluster’s two public classes, NSAttributedString and NSMutableAttributedString , declare the programmatic interface for read-only attributed strings and modifiable attributed strings, respectively.

Traditionally, this means that although it might appear that you're working with an NSAttributedString or NSMutableAttributedString object, you're really operating on an object of some private subclass that offers concrete behavior.

  • In the case of NSAttributedString, you're likely working with an NSConcreteAttributedString instance
  • In the case of NSMutableAttributedString, you're likely working with an NSConcreteMutableAttributedString

You can more clearly see this in Objective-C, where the allocation phase of an object is separate from its initialization phase:

NSAttributedString *str = [NSAttributedString alloc];
NSLog(@"%p (%@)", str, [str class]); // => 0x600000a1b5a0 (NSConcreteAttributedString)

In class clusters, the "public" classes typically override +alloc to return an instance of an object of a different class, and that class may offer the concrete behavior β€” or even override init methods to return yet another private class depending on how they are initialized. For example, with one of the new Markdown methods on NSAttributedString:

NSAttributedString *str = [NSAttributedString alloc];
NSLog(@"%p (%@)", str, [str class]); // => 0x60000267f4c0 (NSConcreteAttributedString)

str = [str initWithMarkdownString:@"**Hello, world!**" options:nil baseURL:nil error:nil];
NSLog(@"%p (%@)", str, [str class]); // => 0x600002678080 (NSConcreteMutableAttributedString)

For the most part, this is entirely hidden from API consumers, but it does come into play when you'd like to participate in the class cluster.


The specific issue here isn't unique to Swift, actually, and happens just the same in Objective-C (and in fact, happens without even overriding any methods):

#import <Foundation/Foundation.h>

@interface MyString: NSMutableAttributedString
@end

@implementation MyString
@end

int main(int argc, char *argv[]) {
    @autoreleasepool {
        [[MyString alloc] initWithString:@"Str"]; //  *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[MyString initWithString:]: unrecognized selector sent to instance 0x6000003ac040'
    }
}

The reason for this becomes a little bit more apparent once you start splitting allocation and initialization apart:

MyString *str = [MyString alloc];
NSLog(@"%@", [str class]); // => MyString

str = [str initWithString:@"Hello, world!"]; // =>  -[MyString initWithString:]: unrecognized selector sent to instance 0x6000004901a0
NSLog(@"%@", str);

Here, +[MyString alloc] actually produces a MyString object, on which we call -initWithString:. Having not overridden -initWithString: from the superclass, this forwards to -[NSMutableAttributedString initWithString:]... which NSMutableAttributedString doesn't actually implement!

NSMutableAttributedString is an abstract class which is never meant to be allocated directly β€” its +alloc returns NSConcreteMutableAttributedString, which does implement the initialization and other backing methods. We can see this a little bit more clearly if we override +alloc ourselves inside of the subclass:

@implementation MyString
    + (instancetype)alloc {
        return [NSMutableAttributedString alloc];
    }
    // ...
@end

// ...

MyString *str = [MyString alloc];
NSLog(@"%@", [str class]); // => NSConcreteMutableAttributedString

// This now dispatches just fine:
str = [str initWithString:@"Hello, world!"];
NSLog(@"%@", str); // => Hello, world!{
}

Of course, this doesn't help us because we're not actually working with a MyString object, but an NSConcreteMutableAttributedString, so any methods we might expect to have called through our class won't actually dispatch.


The NSAttributedString class cluster is a really tricky one to participate in. Unlike, say, NSArray, which offers subclassing notes telling you which methods must be overridden in order to correctly create a subclass which will work, NSAttributedString does not β€” so it's not clear which functionality you'd need to implement manually, except via trial and error.

In this case, you'd need to, for instance, actually provide an implementation of -initWithString: that does not call the superclass, because it's not implemented there.

What are you interested in specifically subclassing from NSMutableAttributedString? With some more info, we might be able to offer help/alternatives.

2 Likes

Great explanation, thank you.

What stops having some version "unavailable from swift subclasses" attribute on "initWithString" (and others?) to make it a compile time error?

I was going to say that the method can't be marked as NS_SWIFT_UNAVAILABLE(...) or similar because then it wouldn't be accessible from Swift as a regular API consumer at all, which wouldn't work β€” but I see your edit specifying "unavailable from Swift subclasses" which is the important distinction.

It's a tricky situation. It's not necessarily that the method should be marked as unavailable from subclasses, because then you couldn't override it β€” and it's definitely valid to override it (in fact, it's required to override it in order to create a subclass). I think a better attribute might be one which expresses "this method is abstract", i.e., "it must be overridden and it can't call super". This type of attribute would also help Obj-C clients wanting to subclass in, since they'd have a clearer sense of what they might need to override.

That'd be an investment into Obj-C more than Swift, though (and it's not clear how much investment would be worth making into this, given that Obj-C has never really supported "true" abstract classes).

[Of course, all of this is a non-issue for pure Swift code, where many "abstract" classes can be expressed in the form of protocols with default implementations, and many class clusters can be done away with since there's no need for a mutable/immutable split β€” e.g., see the new AttributedString type altogether]

1 Like