Hi, everyone. Now that Swift 5 is starting up, I'd like to circle back to an
issue that's been around for a while: the source compatibility of enums. Today,
it's an error to switch over an enum without handling all the cases, but this
breaks down in a number of ways:
- A C enum may have "private cases" that aren't defined inside the original
enum declaration, and there's no way to detect these in a switch without
dropping down to the rawValue. - For the same reason, the compiler-synthesized
'init(rawValue:)' on an imported enum never produces 'nil', because who knows
how anyone's using C enums anyway? - Adding a new case to a *Swift* enum in a
library breaks any client code that was trying to switch over it.
(This list might sound familiar, and that's because it's from a message of mine
on a thread started by Matthew Johnson back in February called "[Pitch]
consistent public access modifiers". Most of the rest of this email is going
to go the same way, because we still need to make progress here.)
At the same time, we really like our exhaustive switches, especially over enums
we define ourselves. And there's a performance side to this whole thing
too; if all cases of an enum are known, it can be passed around much more
efficiently than if it might suddenly grow a new case containing a struct with
5000 Strings in it.
*Behavior*
I think there's certain behavior that is probably not *terribly* controversial:
- When enums are imported from Apple frameworks, they should always require a
default case, except for a few exceptions like NSRectEdge. (It's Apple's job
to handle this and get it right, but if we get it wrong with an imported enum
there's still the workaround of dropping down to the raw value.) - When I
define Swift enums in the current framework, there's obviously no compatibility
issues; we should allow exhaustive switches.
Everything else falls somewhere in the middle, both for enums defined in
Objective-C:
- If I define an Objective-C enum in the current framework, should it allow
exhaustive switching, because there are no compatibility issues, or not,
because there could still be private cases defined in a .m file? - If there's
an Objective-C enum in *another* framework (that I built locally with Xcode,
Carthage, CocoaPods, SwiftPM, etc.), should it allow exhaustive switching,
because there are no *binary* compatibility issues, or not, because there may
be *source* compatibility issues? We'd really like adding a new enum case to
*not* be a breaking change even at the source level. - If there's an
Objective-C enum coming in through a bridging header, should it allow
exhaustive switching, because I might have defined it myself, or not, because
it might be non-modular content I've used the bridging header to import?
And in Swift:
- If there's a Swift enum in another framework I built locally, should it allow
exhaustive switching, because there are no binary compatibility issues,
or not, because there may be source compatibility issues? Again, we'd really
like adding a new enum case to *not* be a breaking change even at the source
level.
Let's now flip this to the other side of the equation. I've been talking about
us disallowing exhaustive switching, i.e. "if the enum might grow new cases
you must have a 'default' in a switch". In previous (in-person) discussions
about this feature, it's been pointed out that the code in an
otherwise-fully-covered switch is, by definition, unreachable, and therefore
untestable. This also isn't a desirable situation to be in, but it's mitigated
somewhat by the fact that there probably aren't many framework enums you
should exhaustively switch over anyway. (Think about Apple's frameworks
again.) I don't have a great answer, though.
For people who like exhaustive switches, we thought about adding a new kind of
'default'—let's call it 'unknownCase' just to be able to talk about it. This
lets you get warnings when you update to a new SDK, but is even more likely to
be untested code. We didn't think this was worth the complexity.
*Terminology*
The "Library Evolution <http://jrose-apple.github.io/swift-library-evolution/>"
doc (mostly written
by me) originally called these "open" and "closed" enums ("requires a default"
and "allows exhaustive switching", respectively), but this predated the use of
'open' to describe classes and class members. Matthew's original thread did
suggest using 'open' for enums as well, but I argued against that, for a few
reasons:
- For classes, "open" and "non-open" restrict what the *client* can do. For
enums, it's more about providing the client with additional guarantees—and
"non-open" is the one with more guarantees. - The "safe" default is backwards:
a merely-public class can be made 'open', while an 'open' class cannot be made
non-open. Conversely, an "open" enum can be made "closed" (making default
cases unnecessary), but a "closed" enum cannot be made "open".
That said, Clang now has an 'enum_extensibility' attribute that does take
'open' or 'closed' as an argument.
On Matthew's thread, a few other possible names came up, though mostly only
for the "closed" case:
- 'final': has the right meaning abstractly, but again it behaves differently