[swift-evolution] Enums and Source Compatibility

Brent Royal-Gordon brent at architechies.com
Wed Sep 6 07:53:20 CDT 2017


> On Sep 5, 2017, at 5:19 PM, Jordan Rose via swift-evolution <swift-evolution at swift.org> wrote:
> 
> I've taken everyone's feedback into consideration and written this up as a proposal: https://github.com/jrose-apple/swift-evolution/blob/non-exhaustive-enums/proposals/nnnn-non-exhaustive-enums.md <https://github.com/jrose-apple/swift-evolution/blob/non-exhaustive-enums/proposals/nnnn-non-exhaustive-enums.md>. The next step is working on an implementation, but if people have further pre-review comments I'd be happy to hear them.

I disagree with the choice of `exhaustive` and `nonexhaustive`. They are too long; the more resilient keyword is longer than the more fragile one (and difficult to read!); and they don't match the clang annotation. We may have to compromise on one or two of these, but the combination of all three ought to be considered disqualifying.

I think `final`/`nonfinal`, `total`/`partial`, `fixed`/? or `permanent`/? are all better because they're shorter, although they all have problems with their antonyms. `candid`/`coy` or `candid`/`shy` produce the right soft default, but are kind of weirdly figurative.

But I don't think a change of keywords will fix everything here. Fundamentally, I am not convinced that source compatibility of `switch` statements should be weighed so heavily. Based on your survey of Foundation, you suggest that the vast majority of imported enums should source-break all switches in Swift 5. Why is that acceptable, but making Swift enums source-breaking unacceptable?

I suspect that, in practice, `public` enums tend to fall into two categories:

	1. "Data enums" which represent important data that happens to consist of a set of alternatives. Outside users will frequently need to switch over these, but they are not very likely to evolve or have private cases.

	2. "Mode enums" which tweak the behavior of an API. These are very likely to evolve or have private cases, but outside users are not very likely to need to switch over them.

An example of a data enum would be, as you mentioned, `NSComparisonResult`. People really *do* need to be able to test against it, but barring some fundamental break in the nature of reality, it will only ever have those three cases. So it's fine to make it exhaustive.

An example of a mode enum would be `UIViewAnimationCurve`, which tells UIKit how to ease an animation. I chose that example because I actually traced a bug just last week to my mistaken impression that this enum had no private cases. I was mapping values of this type to their corresponding `UIViewAnimationOptions` values; because there were private cases, this was Objective-C code, and I didn't include sufficiently aggressive assertions, I ended up reading garbage data from memory. But while debugging this, it struck me that this was actually *really weird* code. How often do you, as a developer outside UIKit, need to interpret the value of a type like `UIViewAnimationCurve`? If the compiler suddenly changed the exhaustiveness behavior of `UIViewAnimationCurve`, probably less than 1% of apps would even notice—and the affected code would probably have latent bugs!

Here's my point: Suddenly treating a mode enum as non-exhaustive is *technically* source-breaking, but *people aren't doing things to them that would break*. It is only the data enums that would actually experience source breakage, and we both seem to agree those are relatively uncommon. So I would argue the relatively rare source breaks are acceptable.

Basically, what I would suggest is this:

	1. In Swift 4.1, we should add a permanent `exhaustive`* keyword and a temporary `@nonexhaustive` attribute to Swift. These are no-ops, or maybe `@nonexhaustive` simply silences the "unreachable default case" warning.

	2. In Swift 4.2 (or whatever Swift 5's Swift 4 mode is called), we should warn about any enum which does not have either `exhaustive` or `@nonexhaustive` attached to it, but publishes them as non-exhaustive. `switch` requires a `default` case for any non-exhaustive public enum.

	3. Swift 5 in Swift 5 mode does the same thing, but does *not* warn about the absence of `@nonexhaustive`.

	4. Swift 5 importing Objective-C treats enums as non-exhaustive by default, unless marked with an attribute.

The dummy keywords in Swift 4.1 ensure that developers can write code that works in both a true Swift 4 compiler and a Swift 5 compiler in Swift 4 mode. (If we don't like that approach, though, we can bump the versions—give Swift 4.2 the behavior I described for Swift 4, give Swift 5 the behavior I described for 4.2, and plan to give Swift 6 the behavior I described for Swift 5.)

* I'm still not super-happy with `exhaustive`, but since `@nonexhaustive` is temporary in this scheme, that at least improves one of the complaints about it. I think the keywords I discussed above would still be improvements.

	* * *

But let's explore an entirely different design. This is a little bit loose; I haven't thought it through totally rigorously.

`SKPaymentTransactionState`, which tells you the status of an in-app purchase transaction, probably would have seemed like a data enum in iOS 3. After all, what states could a transaction take besides `purchasing`, `purchased`, `failed`, or `restored`? But in iOS 8, StoreKit introduced the `deferred` state to handle a new parental-approval feature. Third-party developers did not expect this and had to scramble to handle the unanticipated change.

The frameworks teams often solve this kind of issue by checking the linked SDK version and falling back to compatible behavior in older versions. I don't think StoreKit did this here, but it seems to me that they could have, either by returning the `purchasing` state (which at worst would have stopped users from doing anything else with the app until the purchase was approved or declined) or by returning a `failed` state and then restoring the purchase if it was later approved. At worst, if they had trapped when an incompatible app had a purchase in the `deferred` state, developers might have fixed their bugs more quickly.

I think we could imagine a similar solution being part of our resilience system: Frameworks can add new cases to an enum, but they have to specify compatibility behavior for old `switch` statements. Here's an example design:

A `public enum` may specify the `switch` keyword in its body. (I'm not 100% happy with this keyword, but let's use it for now.) If it does, then the enum is exhaustive:

	// A hypothetical pure-Swift version of `SKPaymentTransaction`.
	@available(iOS 3.0)
	public enum PaymentTransactionState {
		case purchasing
		case purchased(Purchase)
		case restored(Purchase)
		case error(Error)
		
		switch
	}

If it later adds an additional case, or it has non-public cases, it must add a block after the `switch` keyword. The block is called only if `self` is of a case that the calling code doesn't know about; it must either return a value that the caller *does* know about, or trap. So if we added `deferred`, we might instead have:

	@available(iOS 3.0)
	public enum PaymentTransactionState {
		case purchasing
		case purchased(Purchase)
		case restored(Purchase)
		case error(Error)

		@available(iOS 8.0)
		case deferred
		
		switch {
			return .purchasing
		}
	}

(The same logic is applied to the value returned by the block, so if iOS 12 added another case, it could fall back to `deferred`, which would fall back to `purchasing`.)

The `switch` keyword may be followed by a return type; public callers will then need to write their `case` statements as though they were matching against this type. So if, back in iOS 3, you had said this:

	@available(iOS 3.0)
	public enum PaymentTransactionState {
		case purchasing
		case purchased(Purchase)
		case restored(Purchase)
		case error(Error)
		
		switch -> PaymentTransactionState?
	}

Then every `switch` statement on a `PaymentTransactionState` would have had to be written like:

	switch transaction.state {
	case .purchasing?:
		…
	case .purchased?:
		…
	case .restored?:
		…
	case .error?:
		…
	case nil:
		// Handle unexpected states
	}

And then when you added a new case in iOS 8, you could say this, and everyone's code would run through the `nil` path:

	@available(iOS 3.0)
	public enum PaymentTransactionState {
		case purchasing
		case purchased(Purchase)
		case restored(Purchase)
		case error(Error)

		@available(iOS 8.0)
		case deferred
		
		switch -> PaymentTransactionState? {
			return nil
		}
	}

An alternative design would have been to add a `case other` from the start, anticipating that future versions would need to map unknown cases to that one. (Or you could specify `switch -> Never` to forbid switching entirely, or perhaps we could let you say `switch throws` to require the user to say `try switch`. But you get the idea.)

Finally, the kicker: If you do *not* specify an `exhaustive` block, then it is treated as though you had written `switch -> Self? { return nil }`. That is, a "non-exhaustive" enum is just one which turns into an optional when you switch over it, and returns `nil` for unknown cases. Thus, there basically *are* no unknown cases.

Implementation-wise, I imagine that when switching over an enum from `public`, you'd need to make a call which took a version parameter and returned a value compatible with that version. (This might need to be some sort of table of versions, depending on how we end up extending @available to support versions for arbitrary modules.)

	* * *

As for the "untestable code path" problem…maybe we could let you mark certain enum parameters as `@testable`, and then, when brought in through an `@testable import`, allow a `#invalid` value to be passed to those parameters.

	// Library code
	extension PurchasableItem {
		func updateInventory(for state: @testable PaymentTransactionState, quantity: Int) throws {
			switch state {
			case .purchasing:
				return
			case .purchased, .restored:
				inventory += quantity
			case .failed(let error):
				throw error
			default:
				throw ProductError.unknownTransactionState
			}
		}
	}

	// Test
	func testUnknownTransactionState() {
		XCTAssertThrowsError(myProduct.update(for: .#invalid) { error in
			XCTAssertEqual(error, ProductError.unknownTransactionState)
		}
	}

An `@testable` value could not be passed to a non-`@testable` parameter or into a non-`@testable` module, including the actual module the original type came from, unless you had somehow excluded the possibility of an `#invalid` value. You would need to design your code rather carefully to work around this constraint, but I think it could be done.

-- 
Brent Royal-Gordon
Architechies

-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://lists.swift.org/pipermail/swift-evolution/attachments/20170906/69406322/attachment.html>


More information about the swift-evolution mailing list