Hi swift-evolution,
I'd like to pitch a proposal that is basically SE-0192, but for library authors that don't care about binary stability.
I've complained about the problem earlier, and now I'd like to suggest a solution.
Handling future cases of enums in libraries without binary stability concerns
- Proposal: SE-NNNN
- Authors: Sergej Jaskiewicz
- Review Manager: TBD
- Status: Awaiting implementation
Introduction
SE-0192 introduced a mechanism to add new cases to enums defined in the standard library and overlays in a source-compatible way. SE-0260 later lifted this restriction, allowing to use this mechanism in all libraries built in library evolution mode.
We propose to remove this restriction completely by:
- allowing all library authors to mark their public enums as
@frozen
; - warning the clients that exhaustively switch over a non-frozen enum declared in another module, suggesting them to add an
@unknown default
case.
Motivation
First, let's agree on terminology. We'll call a library with binary compatibility concerns (and therefore built in library evolution mode) a resilient library. Other libraries are referred to as non-resilient.
Suppose we're developing an open source library, and we are not planning to distribute it in binary form (for example, we want to use Swift Package Manager). A part of our library's public API is an enum with several cases, and we want to be able to add new cases to that enum without breaking the code that depends on our library.
public enum Unit {
case minutes(Int)
case seconds(Int)
case milliseconds(Int)
}
Right now we can't do it, unless we build our library in library evolution mode. So, the clients of the library that exhaustively switch over the enum will stop compiling, should we ever add a new case. This is unexpected, since we already have a way to handle unknown enum cases by using @unknown default
. Suppose our client knows about this and adds this case to their switch
:
public func printUnit(_ unit: Unit) {
switch unit {
case . unit minutes(let n):
print("\(n) minutes")
case .seconds(let n):
print("\(n) seconds")
case .milliseconds(let n):
print("\(n) milliseconds")
@unknown default:
print("unknown")
}
}
The client will get a warning saying that the default case will never be executed. There is a client-side workaround to avoid the warning and breakage in case a new case is added:
public func printUnit(_ unit: DispatchTimeInterval) {
switch interval {
case .seconds(let n):
print("\(n) seconds")
case .milliseconds(let n):
print("\(n) milliseconds")
case .microseconds(let n):
print("\(n) microseconds")
case .nanoseconds(let n):
print("\(n) nanoseconds")
default:
if case .never = interval {
print("never")
} else {
print("unknown")
}
}
}
This workaround accomplishes the goal, but has two disadvantages:
- When a new case is added, the client will have no way of knowing that they should fix their code to handle the new case. If the client used
@unknown default
, they would get a warning (rather than an error) that suggests adding a missing case; - As already mentioned, this workaround is client-side. There is no way for library authors to enforce this.
This is weird: even if we only want better source stability, we have to build our library in library evolution mode, which is all about binary stability.
This also has a very unpleasant side-effect: since there is no notion of resilient libraries on non-Darwin platforms like Linux and Windows, all the enums declared in swift-corelibs-foundation and swift-corelibs-libdispatch are frozen. This affects users that want to write cross-platform code: if they switch over an enum like DispatchTimeInterval
that is non-frozen on Darwin, and they use @unknown default
case, they get a warning on Linux, but not on Darwin. If they don't use @unknown default
, they get a warning on Darwin, but not on Linux.
Proposed solution
- Make all public enums in non-resilient libraries non-frozen by default.
- Allow authors of those libraries to mark their public enums
@frozen
to indicate that no new cases will be added.
Detailed design
This section is excatly the same as in SE-0192.
Here we list some enums that would benefit from this proposal:
- All enums in swift-corelibs-foundation and swift-corelibs-libdispatch that are non-frozen in Foundation and Dispatch overlays on Darwin platforms, including:
Calendar.Component
,Calendar.Identifier
,Calendar.MatchingPolicy
,Calendar.RepeatedTimePolicy
,Calendar.SearchDirection
,Data.Deallocator
,DispatchData.Deallocator
,DispatchIO.StreamType
,DispatchPredicate
,DispatchQoS.QoSClass
,DispatchQueue.AutoreleaseFrequency
,DispatchTimeInterval
,JSONDecoder.DataDecodingStrategy
,JSONDecoder.DateDecodingStrategy
,JSONDecoder.KeyDecodingStrategy
,JSONDecoder.NonConformingFloatDecodingStrategy
,JSONEncoder.DataEncodingStrategy
,JSONEncoder.DateEncodingStrategy
,JSONEncoder.KeyEncodingStrategy
,JSONEncoder.NonConformingFloatEncodingStrategy
,POSIXErrorCode
-
WebSocketErrorCode
from swift-nio -
Logger.MetadataValue
and maybeLogger.Level
from swift-log.
Source compatibility
Users that exhaustively switch over a public enum declared in another module will get a warning that will suggest adding a @unknown default
case.
So, this change is completely source-compatible, unless you use the -warnings-as-errors
flag.
Effect on ABI stability
This change affects only non-resilient libraries and therefore has no effect on ABI stability.
Effect on API resilience
Marking a public enum @frozen
is a source-compatible change. Users that used @unknown default
case when switching over a formerly non-frozen enum will get a warning "default case will never be executed".
Removing the @frozen
attribute from a public enum is also a source-compatible change: users that exhaustively switch over a formerly frozen enum will get a warning that suggests adding an @unknown default
case.
Alternatives considered
We could leave it as-is. For the reasons described in the Motivation section, this is suboptimal.