Case closed: a dive into open enumerations

Time for a pop quiz. Given that NSFetchedResultsChangeType is defined as follows:

typedef NS_ENUM(NSUInteger, NSFetchedResultsChangeType) {
	NSFetchedResultsChangeInsert = 1,
	NSFetchedResultsChangeDelete = 2,
	NSFetchedResultsChangeMove = 3,
	NSFetchedResultsChangeUpdate = 4
} NS_ENUM_AVAILABLE(NA, 3_0);

What is the value of changeType in the following expression (assuming Swift 3.x)?

let changeType = NSFetchedResultsChangeType(rawValue: 5)

Hint: Enumerations with raw values in Swift implicitly conform to RawRepresentable (⇒ the presence of init?(rawValue:)).

(Corgi for padding between question and answer)

A photo posted by Zoey Corgella the corgi (@zoeydacorgi) on

Turns out changeType is non-nil, works in a switch statement, and has undefined behavior!

To understand why this is the case1, we have to take a step back and define two terms: open and closed enumerations. NSFetchedResultsChangeType is an NS_ENUM and when bridged to Swift, it’s represented as an “open” enumeration. The distinction between open and closed enumerations can be found in Jordan Rose’s manifesto on Swift Library Evolution. In summary, open enumerations will be the default once ABI resilience ships and allow the following changes between library versions without breaking binary compatibility:

  • Adding a new case2
  • Reordering existing cases is a binary-compatible source-breaking change. In particular, if an enum is RawRepresentable, changing the raw representations of cases may break existing clients who use them for serialization
  • Adding a raw type to an enum that does not have one
  • Removing a non-public, non-versioned case
  • Adding any other members
  • Removing any non-public, non-versioned members
  • Adding a new protocol conformance (with proper availability annotations)
  • Removing conformances to non-public protocols

On the flip side, closed enumarations must be explicitly marked with the @closed attribute. This attribute prevents the enumeration from“having any cases with less access than the enum itself3 and adding new cases in the future.” In addition to guaranteeing case exhaustion for clients, the attribute implies:

  • Adding new cases is not permitted
  • Reordering existing cases is not permitted.
  • Adding a raw type to an enum that does not have one is still permitted.
  • Removing a non-public case is not applicable.
  • Adding any other members is still permitted.
  • Removing any non-public, non-versioned members is still permitted.
  • Adding a new protocol conformance is still permitted.
  • Removing conformances to non-public protocols is still permitted.

Despite “open” being the default for enumerations moving forward, adding new cases (and consequences of this) can lead to gnarly bugs like the one my teammate, Paul, and I found earlier this week.

Invalid Changes Sent to NSFetchedResultsControllerDelegate

NSFetchedResultsControllerDelegate provides an optional function to notify concrete implementations that a fetched object has been changed due to an add, remove, move, or update. During testing, we noticed“extra” calls4 to this function, which caused crashes via subsequent invalid table view updates. To set some context, our concrete implementation had the following structure:

public func controller(_ controller: NSFetchedResultsController<NSFetchRequestResult>,
               didChange anObject: AnyObject,
                      at indexPath: IndexPath?,
                     for type: NSFetchedResultsChangeType,
            newIndexPath: IndexPath?) {

	/* Precondition checks */

	// ...

	switch type {
	case .insert:
	/* Insertion logic */
	case .delete:
	/* Deletion logic */
	case .move:
	/* Move logic */
	case .update:
	/* Update logic */
	}
}

Breakpointing on calls to this function, we noticed something odd in the debug menu:

Looks like Core Data is making calls to this delegate function with invalid NSFetchedResultsChangeTypes! However, due to the nature of open enumerations, we have no way of disambiguating this from a scenario where a new case is added (i.e. NSFetchedResultsChangeType(rawValue: x) != nil, ∀x ∈ {UInt(0), UInt(1), UInt(2), ... , UInt.max}, due to the lack of guaranteed case exhaustion). To make this even trickier to find, the example case seemed to match against the first case, .insert, (as Caleb noted in his testing as well). But, this behavior is technically undefined.

Getting Around This

To safeguard against this bug until a default case is required for open enumerations, we came up with an imperfect workaround. In the delegate function body, we added a guard to ensure the presence of a valid raw value on the type parameter:

guard [
		NSFetchedResultsChangeType.update.rawValue,
		NSFetchedResultsChangeType.delete.rawValue,
		NSFetchedResultsChangeType.insert.rawValue,
		NSFetchedResultsChangeType.move.rawValue
	].contains(type.rawValue) else {

	/* Note about the open enumeration issue we’ve described and logging to track this in production */
	return
}

This keeps our code crash-free until ABI resilience alleviates the need for the guard. Of course, this doesn’t handle the scenario in which Apple adds a new case, but that’s why we perform logging in the else clause to keep an eye on any API updates. You can watch this issue on Swift’s JIRA.

Hope this helps make open and closed enumerations more clear! Huge shoutout to Joe Groff, Caleb Davenport, and Jordan Rose for discussing this topic in the open on Twitter and in the Swift repository docs.


Footnotes:

  1. Pun intended 😁 

  2. We’ll soon see why this feature causes changeType in the quiz to be non-nil

  3. Non-public cases don’t exist at the moment

  4. Masked as insertions due to this undefined behavior