objective-cnsattributedstringnsobjectnsmutableattributedstring

Why can't I mutate an NSObject allocated as mutable, referenced as immutable, then cast back to mutable?


I'm allocating an NSMutableAttributedString, then assigning it to the attributedString property of an SKLabelNode. That property is an (NSAttributedString *), but I figured that I could cast it to an (NSMutableAttributedString *) since it was allocated as such. And then access its mutableString property, update it, and not have to do another allocation every time I want to change the string.

But after the cast, the object is immutable and an exception is thrown when I try to mutate it.

Is it true that I can't mutate an NSObject that was allocated as mutable just because it was referenced as immutable?


Solution

  • Is it true that I can't mutate an NSObject that was allocated as mutable just because it was referenced as immutable?

    No, your general intuition here is correct. Ignoring for a second the concept of "mutable" and "immutable" in general, but focusing on the subclassing relationship between NS<SomeType> and NSMutable<SomeType>: typically, Apple framework objects which have mutable/immutable counterparts have the mutable variant as a subtype of the immutable variant. Assigning a mutable variable to an immutable variable does not change anything about the stored variable, same as the following does not:

    @interface Foo: NSObject @end
    @implementation Foo @end
    
    @interface Bar: Foo @end
    @implementation Bar @end
    
    Foo *f = [[Bar alloc] init];
    NSLog(@"%@", f); // => <Bar: 0x6000014b0040>
    

    You can see something similar with NSMutableAttributedString (though it's a little more complicated because NSAttributedString and subtypes form a class cluster:

    NSAttributedString *s = [[NSMutableAttributedString alloc] initWithString:@"Hello"];
    NSLog(@"%@", [s class]); // => NSConcreteMutableAttributedString
    

    However: the key difference between assigning to a local variable like with f and s above, and assigning to an SKLabelNode's attributedText property lies in the property's definition:

    @property(nonatomic, copy, nullable) NSAttributedString *attributedText;
    

    Specifically, SKLabelNode performs a copy on assignment to its attributedText property, and performing a copy on an NSMutableAttributedString produces an immutable variant:

    NSAttributedString *s = [[[NSMutableAttributedString alloc] initWithString:@"Hello"] copy];
    NSLog(@"%@", [s class]); // => NSConcreteAttributedString
    

    So, when you assign to your SKLabelNode in this way, it doesn't store your original instance, but a copy of its own — and it happens to be that this copy is immutable.


    Note that this is behavior is a confluence of two things:

    1. SKLabelNode chooses to -copy the assigned variable; if it -retained it instead (e.g. @property(nonatomic, strong, nullable)), this would work as you expected
    2. NSMutableAttributedString returns an NSAttributedString from its -copy method, but it doesn't have to. In fact, most types return instancetype from -copy, but NSMutableAttributedString chooses to return an NSAttributedString from its -copy method. (Well, that is the point of the class cluster: -copy → immutable, -mutableCopy → mutable)

    So in general, this need not be the case, but you will see this behavior for mutable/immutable class clusters which are implemented using these rules.

    For comparison, with the Foo example above:

    @interface Foo: NSObject @end
    @implementation Foo
    - (instancetype)copyWithZone:(NSZone *)zone {
        // Expects to return a new Foo:
        return [[[self class] alloc] init];
    
        // OR:
        // Not all types allow copying:
        return self;
    }
    @end
    
    @interface Bar: Foo @end
    @implementation Bar @end
    
    Foo *f = [[[Bar alloc] init] copy];
    NSLog(@"%@", f); // => <Bar: 0x600001e7c1a0>