Blog Post

NSCopying in a Swift World

Illustration: NSCopying in a Swift World

This story begins when a customer of our iOS PDF SDK reported that when they used a subclass of our Document class, taking a certain action in our user interface (UI) would crash with this message:

Fatal error: Use of unimplemented initializer ‘init(dataProviders:loadCheckpointIfAvailable:)’

The crash was easy for us to reproduce. When implementing init(dataProviders:loadCheckpointIfAvailable:) in the Document subclass, the crash didn’t occur, but we don’t want our customers to need to implement it themselves to avoid crashing.

We’re fortunate to have a codebase that’s been continuously maintained while serving users in production since 2011. Thanks to the fantastic interoperability between Swift and Objective-C, we’re able to write all our new code in Swift while keeping older Objective-C code around.

However, coming up with a solution for the reported crash required a dive into Swift and Objective-C compatibility and reevaluating old code to apply a more modern programming mindset. That’s what we’ll explore in this post.

The Designated Initialiser Pattern

To understand what’s going on, it’s necessary to cover some theory about how object initialisation works with class inheritance. Both Swift and Objective-C follow a few rules that make up the designated initialiser pattern. These rules are similar between the two languages, though they’re more strongly enforced by the compiler in Swift.

Consider two classes, a base class and a subclass. In other words:

class BaseClass {
}

class Subclass: BaseClass {
}

I started programming in Objective-C around 2010, and the relevant rules of the designated initialiser pattern are burned into my brain:

  • Rule 1 — The subclasses’s designated initialisers must each call through to one of the designated initialisers of the superclass using super.

  • Rule 2 — Override all the superclass’s designated initialisers and call through to one of the subclasses’s designated initialisers using self.

If you’ve used Swift but not Objective-C, you’re probably familiar with the first rule, but possibly not the second. We’ll understand why shortly.

To read more, see Apple’s documentation on Class Inheritance and Initialization in The Swift Programming Language and Object Initialization in Adopting Modern Objective-C.

An Example… Leading to the Problem with the Designated Initialiser Pattern

Let’s say we’re a fictional customer of PSPDFKit building a social networking app for sharing PDFs. We need to subclass the Document class from PSPDFKit to add a property, numberOfLikes. The relevant part of the interface of Document is:

class Document {
    init(dataProviders: [DataProviding], loadCheckpointIfAvailable: Bool)
}

We implement rule 1 by defining the designated initialiser for our subclass, SocialDocument:

class SocialDocument: Document {
    let numberOfLikes: Int

    init(dataProviders: [DataProviding], numberOfLikes: Int) {
        self.numberOfLikes = numberOfLikes
        super.init(dataProviders: dataProviders, loadCheckpointIfAvailable: false)
    }
}

Rule 2 is tricky though; we need to know the number of likes. Perhaps we could override the superclass’s designated initialisers and just set the number of likes to zero:

class SocialDocument: Document {
    // [...]

    override convenience init(dataProviders: [DataProviding], loadCheckpointIfAvailable: Bool) {
        self.init(dataProviders: dataProviders, numberOfLikes: 0)
    }
}

However, let’s say we really need that number of likes; we can’t simply assume zero. We could treat this as a programmer error and abort the process. A practical alternative interpretation of the second rule is:

  • Alternative rule 2 — Override all the superclass’s designated initialisers and cleanly terminate the process with a clear error message.

Our implementation would then look like this:

class SocialDocument: Document {
    // [...]

    override convenience init(dataProviders: [DataProviding], loadCheckpointIfAvailable: Bool) {
        fatalError("Calling initializer 'init(dataProviders:loadCheckpointIfAvailable:)' is not allowed for SocialDocument.")
    }
}

Of course, in Swift, a better solution is to not override this initialiser and then the compiler won’t let us call it. The second rule isn’t needed in Swift.

However, the compiler can only enforce this in Swift. If your class might be accessed from Objective-C, the superclasses’s designated initialiser might be called. We’ll cover why later on. Behind the scenes, the Swift compiler will synthesise an override of the superclass’s designated initialisers to cleanly crash, as shown above, except the error message used is Use of unimplemented initializer 'init(dataProviders:loadCheckpointIfAvailable:)'.

As a side note, in our Objective-C code in PSPDFKit, we follow a pattern similar to Swift when we want to change the designated initialisers in a subclass:

  • In our interface, mark the superclass’s designated initialisers as unavailable. This means the compiler would catch most cases of trying to call the wrong initialiser. However unlike in Swift, it won’t catch all cases.

  • In our implementation, override the superclass’s designated initialisers and cleanly terminate the process.

Don’t worry; we’re not dinosaurs writing these overrides for every class: We use C preprocessor macros!

NSCopying

NSCopying, from Apple’s Foundation framework, is a “protocol that objects adopt to provide functional copies of themselves”. The copyright at the top of the header file where this is protocol is declared, NSObject.h, starts in 1994.

The documentation for copyWithZone: says “the exact nature of the copy is determined by the class”. I interpret this to mean it’s up to us whether or not to return a subclass.

Swift wisely avoids supporting copying instances of classes in its standard library. Instead, Swift structs are value types, so they’re implicitly copyable, and they don’t have any subclassing concerns.

NSCopying the Unsafe Way

Let’s recap the situation:

  • The Document class in our framework has the designated initialiser init(dataProviders:loadCheckpointIfAvailable:).

  • The customer creates SocialDocument as a subclass of Document with the designated initialiser init(dataProviders:numberOfLikes:).

Document conforms to the NSCopying protocol. Internally, PSPDFKit creates copies of document objects in some cases.

We can reproduce the crash the customer saw like this:

let originalDocument = SocialDocument(dataProviders: [fileDataProvider], numberOfLikes: 5)
let copiedDocument = originalDocument.copy() // 💥

On the call to copy(), the process crashes with:

Fatal error: Use of unimplemented initializer ‘init(dataProviders:loadCheckpointIfAvailable:)’

This takes us back to the discussion of “alternative rule 2” above. This crash happens because, behind the scenes, the Swift compiler synthesises overrides of a superclass’s designated initialisers. These overridden initialisers crash to prevent objects from being incorrectly initialised from Objective-C.

PSPDFKit’s implementation of NSCopying on Document is in Objective-C. For all other code in this post, it doesn’t matter much whether Swift or Objective-C is used, but this is the crucial part that only shows the problem in Objective-C. PSPDFKit’s old implementation was like this:

- (id)copyWithZone:(nullable NSZone *)zone {
    let copiedDocument = [[self.class alloc] initWithDataProviders:self.dataProviders loadCheckpointIfAvailable:NO];
    // Apply some internal state to the new object.
    // [...]
    return copiedDocument;
}

(We deliberately don’t copy the value of loadCheckpointIfAvailable, but this detail isn’t relevant.)

From a quick look on Stack Overflow, it seems [self.class alloc] is often a recommended way to create a copy in Objective-C. However, the problem is that the use of self.class dynamically looks up the subclass SocialDocument, but the code here in our framework has no idea that SocialDocument has changed the initialisation requirements.

Initialising a subclass like this would work fine if the designated initialiser pattern was followed to the letter (using “rule 2” discussed above), but this isn’t compatible with Swift’s safer approach to programming.

NSCopying in Swift

Let’s try to implement NSCopying for Document the same way as above, but using Swift. We use Self (with a capital S) as the Swift way to dynamically look up the class:

extension Document: NSCopying {
    func copy(with zone: NSZone? = nil) -> Any {
        let copiedDocument = Self(dataProviders: self.dataProviders, loadCheckpointIfAvailable: false) // ❌
        // Apply some internal state to the new object.
        // [...]
        return copiedDocument
    }
}

The compiler shows this error:

❌ Constructing an object of class type ‘Self’ with a metatype value must use a ‘required’ initializer

This is scary compiler engineer jargon, but what it basically says is that because the exact subclass will only be known when running the code (not when compiling), we can only call an initialiser marked as required. When an initialiser is required, the compiler enforces that the initialiser is available on all subclasses. In other words, if a class changes the initialisers, that subclass must also override the required initialiser.

Solution 1: Required Initialiser

We can do what the compiler error says. We change the interface of Document so its designated initialiser is marked as required:

class Document {
    required init(dataProviders: [DataProviding], loadCheckpointIfAvailable: Bool)
}

Document’s copy(with:) implementation shown above now compiles, but this means that subclasses of Document must override this initialiser, so SocialDocument will have this compiler error:

❌ ‘required’ initializer ‘init(dataProviders:loadCheckpointIfAvailable:)’ must be provided by subclass of ‘Document’

Since numberOfLikes is an essential state, the best compromise is to change that property from let to var, and then to override copyWith(:) to copy this value:

class SocialDocument: Document {
    // We want this to be `let`, but it must be `var` because
    // of the required initialiser from the superclass.
    var numberOfLikes: Int

    // The designated initialiser (no change here).
    init(dataProviders: [DataProviding], numberOfLikes: Int) {
        self.numberOfLikes = numberOfLikes
        super.init(dataProviders: dataProviders, loadCheckpointIfAvailable: false)
    }

    // Override the required initialiser. Use a temporary
    // wrong value of zero for the number of likes.
    required convenience init(dataProviders: [DataProviding], loadCheckpointIfAvailable: Bool) {
        self.init(dataProviders: dataProviders, numberOfLikes: 0)
    }

    override func copy(with zone: NSZone? = nil) -> Any {
        let copiedDocument = super.copy(with: zone) as! SocialDocument
        copiedDocument.numberOfLikes = self.numberOfLikes
        return copiedDocument
    }
}

(Note that the compiler enforces that the override of the required initialiser is also marked required, since this requirement would cascade if there was a subclass of SocialDocument.)

This solution isn’t great: Subclasses can’t add any read-only (let) properties. The subclasses need to override an initialiser using a placeholder value and then override copy(with:) to replace this temporary value with the real value.

As an aside, our Document class is internally implemented as an Objective-C class called PSPDFDocument. To implement a class in Objective-C with an initialiser that maps to a required initialiser in Swift, we’d have to (perhaps non-obviously) declare the initialiser in a @protocol instead of in the class @interface, and then make the class conform to that protocol. Thanks to Kostiantyn Herasimov for pointing this out. This setup wouldn’t be ideal, because the extra level of abstraction would show up in our public API even though it’s not something our customers would need to interact with.

Solution 2: Copying Creates an Instance of the Base Class

An alternative approach is for Document to implement copy(with:) without attempting to create an instance of a subclass:

extension Document: NSCopying {
    func copy(with zone: NSZone? = nil) -> Any {
        let copiedDocument = Document(dataProviders: self.dataProviders, loadCheckpointIfAvailable: false)
        // Apply some internal state to the new object.
        // [...]
        return copiedDocument
    }
}

With this, calling copy() on an instance of SocialDocument will return an instance of Document rather than SocialDocument. This is a limitation, but overall, I think it’s easier to understand and is preferable to all the requirements of solution 1.

If Document were a simpler type where all state that should be copied was public, then subclasses that required copying to create instances of the subclass could override copy(with:) without calling super, like this:

class SocialDocument: Document {
    // [...]

    override func copy(with zone: NSZone? = nil) -> Any {
        let copiedDocument = SocialDocument(dataProviders: self.dataProviders, numberOfLikes: self.numberOfLikes)
        // Apply mutable state from the superclass.
        // [...]
        return copiedDocument
    }
}

In reality, our Document class has some internal state that needs copying, and we didn’t pursue this further. If our customers let us know that having NSCopying create instances of subclasses is important for them, we’ll come back to this.

Conclusions

The Objective-C designated initialiser pattern has the limitation that subclasses can’t add additional initialisation requirements, because the designated initialisers of the superclass still need to be functional.

There isn’t a nice way to make copying subclasses work while still adhering to Swift’s principle of reducing the amount of mutable state by using let to create read-only properties. The nicest way is for an implementation of NSCopying to not attempt to create instances of a subclass.

NSCopying and Swift come from different eras and don’t like each other, so we should avoid using them together if possible. Swift avoids most of the need for copying instances of classes by making structs first-class value types.

Author
Douglas Hill iOS Team Lead

Douglas makes the most of our fully remote setup by frequently traveling around Europe by train. He’s a proponent of iPad as a productivity platform and is obsessive about the details of great user experiences. He’s also an organizer of the NSLondon meetup.

Related products
Share post
Free trial Ready to get started?
Free trial