Enum cases as protocol witnesses
- Proposal: SE-XXXX
- Author: Suyash Srijan
- Review Manager: TBD
- Status: Awaiting Review
- Implementation: apple/swift#28916
- Bug: SR-3170
- Toolchain: macOS & Linux
Introduction
The aim of this proposal is to lift an existing restriction, which is that enum cases cannot participate in protocol witness matching.
Swift-evolution thread: Enum cases as protocol witnesses
Motivation
Currently, Swift has a very restricive protocol witness matching model where a protocol witness has to match exactly with the requirement, with some exceptions (see Protocol Witness Matching Manifesto).
For example, if one writes a protocol with static requirements:
protocol DecodingError {
static var fileCorrupted: Self { get }
static func keyNotFound(_ key: String) -> Self
}
and attempts to conform an enum to it, then writing a case with the same name (and arguments) is not considered a match:
enum JSONDecodingError: DecodingError {
case fileCorrupted // error, because it is not a match
case keyNotFound(_ key: String) // error, because it is not a match
}
This is quite surprising, because even though cases are not written as a static var
or static func
, they do behave like one both syntactically and semantically throughout the language. For example:
enum Foo {
case bar(_ value: Int)
}
let f = Foo.bar // `f` is a function of type (Int) -> Foo
let bar = f(2) // Returns Foo
is the same as:
struct Foo {
static func bar(_ value: Int) -> Self { ... }
}
let f = Foo.bar // `f` is a function of type (Int) -> Foo
let bar = f(2) // Returns Foo
Now, because enum cases are not considered as a "witness" for static protocol requirements, one has to provide a manual implementation instead:
enum JSONDecodingError: DecodingError {
case _fileCorrupted
case _keyNotFound(_ key: String)
static var fileCorrupted: Self { return ._fileCorrupted }
static func keyNotFound(_ key: String) -> Self { return ._keyNotFound(key) }
}
This leads to some rather unfortunate consequences:
- The ability to write a case with the same name as the requirement is lost. Now, you can rename the case to something different, but it might not always be ideal, especially because naming things right is a really hard problem. In most cases, you expect the case to be named the same as the requirement.
- The namespace of the enum is now polluted with both cases and requirements (for example, in the snippet above we have
_fileCorrupted
andfileCorrupted
), which can be confusing during code completion. - There's extra code that now has to be maintained and which arguably should not exist in the first place.
In almost every corner of the language, enum cases and static properties/functions are indistinguishable from each other, except when it comes to matching protocol requirements, which is very inconsistent. It is not unreasonable to think of a enum case without associated values as a static
, get-only property that returns Self
or an enum case with associated values as a static
function (with arguments) that returns Self
.
Proposed Solution
The current restriction is lifted and the compiler allows a static protocol requirement to be witnessed by an enum case, under the following rules:
- A static, get-only protocol requirement having an enum type or
Self
type can be witnessed by an enum case with no associated values. - A static function requirement with arguments and returning an enum type or
Self
type can be witnessed by an enum case with associated values having the same argument list as the function's.
This means the example from the motivation section will successfully compile:
enum JSONDecodingError: DecodingError {
case fileCorrupted // okay
case keyNotFound(_ key: String) // okay
}
This also means the mental model of an enum case will now be more consistent with static properties/methods and an inconsistency in the language will be removed.
You will still be able to implement the requirement manually if you want and code that currently compiles today (with the manual implementation) will continue to compile. However, you will now have the option to let the case satisfy the requirement directly.
Here are a few more examples that demonstrate how cases will be matched with the requirements:
protocol Foo {
static var zero: FooEnum { get }
static var one: Self { get }
static func two(arg: Int) -> FooEnum
static func three(_ arg: Int) -> Self
static func four(_ arg: String) -> Self
static var five: Self { get }
static func six(_: Int) -> Self
static func seven(_ arg: Int) -> Self
static func eight() -> Self
}
enum FooEnum: Foo {
case zero // okay
case one // okay
case two(arg: Int) // okay
case three(_ arg: Int) // okay
case four(arg: String) // not a match
case five(arg: Int) // not a match
case six(Int) // okay
case seven(Int) // okay
case eight // not a match
}
The last one is intentional - there is no way to declare a case eight()
today (and even when you could in the past, it actually had a different type). In this case, the requirement static func eight()
can infact be better expressed as a static var eight
. In the future, this limitation may be lifted when other kinds of witness matching is considered.
Source compatibility
This does not break source compatibility since it's an additive change and allows code that previously did not compile to now compile and run successfully.
Effect on ABI stability
This does not affect the ABI (I think?) and does not require new runtime support.
Effect on API resilience
Library authors can freely switch between having the enum case satisy the requirement or implementing the requirement directly (and returning a different case). However, while the return type will stay the same, the return value will be different in both cases and so it can break code that is relying on the exact returned value.
For example:
protocol Foo {
static var one: Self { get }
}
enum Bar: Foo { // #1
case one
}
enum Bar: Foo { // #2
case _one
static var one: Self { return ._one }
}
func takesFoo<T: Foo>(v: T) { ... }
let direct = Bar.one // #1
let asVar = Bar.one // #2
takesFoo(v: direct) // This one recieves .one
takesFoo(v: asVar) // This one recieves ._one
Alternatives considered
- Leave the existing behaviour as-is
Future directions
We can allow for more kinds of witness matching, as described in the Protocol Witness Matching Manifesto, such as subtyping and default arguments.