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 struct
s 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 initialiserinit(dataProviders:loadCheckpointIfAvailable:)
. -
The customer creates
SocialDocument
as a subclass ofDocument
with the designated initialiserinit(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 struct
s first-class value types.