> On Sep 5, 2017, at 5:19 PM, Jordan Rose via swift-evolution
> <[email protected]> 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
_______________________________________________
swift-evolution mailing list
[email protected]
https://lists.swift.org/mailman/listinfo/swift-evolution