Enum cases as protocol witnesses

Enum cases as protocol witnesses

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:

  1. 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.
  2. The namespace of the enum is now polluted with both cases and requirements (for example, in the snippet above we have _fileCorrupted and fileCorrupted), which can be confusing during code completion.
  3. 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:

  1. A static, get-only protocol requirement having an enum type or Self type can be witnessed by an enum case with no associated values.
  2. 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.

38 Likes

Finally. :heart_eyes:

9 Likes

I don't see a motivation for this addition, and I'm pretty sure I won't ever use it — but it doesn't hurt me either, so that is no reason for opposition.
However, I'm not sure if it won't hurt other people who don't participate here: How will this change affect documentation? I think that is actually more important than the source, which will only be seen by a minority.

I'm not sure if this new behavior is easy to explain, and when solutions are hard to explain, there's often something wrong with them.

2 Likes

The main motivation for me to have this is to be able to enforce certain enum cases and have a common super-type in generic code. I would strongly argue that this feature is an implication of the enum design. Cases take the static space which means you can‘t have an enum with a case named foo and also have a static member named foo.

4 Likes

This is something I've wanted for a long time. Here's an example from something I've been working on recently that could greatly benefit from this:

Before:

enum CommonErrorReasonCode: Int {
    // Common
    case unknown = -1
    case networkError = 17020
    case tooManyRequests = 17010
}

enum AuthErrorReasonCode: Int {
    // Common
    case unknown = -1
    case networkError = 17020
    case tooManyRequests = 17010

    // Auth-related
    case invalidCredential = 17004
    case invalidEmail = 17008
    case userDisabled = 17005
}

struct CommonError: Error {
    var reasonCode: CommonErrorReasonCode
    var localizedDescription: String
}

struct AuthError: Error {
    var reasonCode: AuthErrorReasonCode
    var localizedDescription: String
}

After:

protocol CommonErrorReasonCode {
    static var unknown: Self { get }
    static var networkError: Self { get }
    static var tooManyRequests: Self { get }
}

protocol AuthErrorReasonCode {
    static var invalidCredential: Self { get }
    static var invalidEmail: Self { get }
    static var userDisabled: Self { get }
}

enum ErrorReasonCode: Int, CommonErrorReasonCode, AuthErrorReasonCode {
    // Common
    case unknown = -1
    case networkError = 17020
    case tooManyRequests = 17010

    // Auth-related
    case invalidCredential = 17004
    case invalidEmail = 17008
    case userDisabled = 17005
}

struct Error<T>: Swift.Error {
    var reasonCode: T
    var localizedDescription: String
}

typealias CommonError = Error<CommonErrorReasonCode>
typealias AuthError = Error<CommonErrorReasonCode & AuthErrorReasonCode>

A lot easier to maintain, safer, and efficient IMO.

8 Likes

But this isn't about special "enum-protocols", is it?
How useful is something like protocol DecodingError in generic context when it destroys the main feature of an enum — or will it still be possible to do exhaustive switching?
Maybe I'm missing something, but I don't see how it could be possible to match a function...

1 Like

I'm super excited to see this pitch. I have wanted this feature for quite a while! Are you planning to work on an implementation for this pitch so we can take it through review?

I was surprised to earn that case three above is supported. I didn't think it was because enum cases don't support argument labels in general. But to my surprise, it does compile. So while we can't specify a different argument label from the name of the associated value, we can discard the argument label.

That said, I think we should support argument labels for enum cases. The need for this becomes especially clear in the context of this pitch. Protocols will be defined in terms of function syntax and often use labels that make no sense as the name of an associated value. This would also benefit enum cases in general, allowing the factory functions they define to have labels aligned with syntax of static functions we can declare manually. We shouldn't have to write a forwarding static function to get "Swifty" factory argument labels as well as good associated value names for use in bindings.

I understand the logic in saying that seven does not match. However, I find it unfortunate that this approach would mean that no enum case would.be able to match a no-argument factory method requirement. Maybe this isn't a huge deal because the usual convention would be to make it a static property instead. But it's worth giving it some consideration.

2 Likes

The implementation is ready (link in the pitch description above)

1 Like

Yeah, although I think this would require new mangling which would break ABI but perhaps it could be avoided by introducing a new compiler flag or attribute.

I think it can be done separately to this pitch (I think it’s part of SE-0155).

Yes please! :sparkling_heart:

2 Likes

I'm glad you mentioned this—I'm torn on this one, and it's definitely worth calling out as a special case (heh) in the proposal.

If this isn't supported, then protocol requirements of the form static func f() -> Self cannot be satisfied without writing a forwarding function, which is what this pitch aims to avoid.

On the other hand, automagically letting what is effectively a static var f: Self { get } under the hood satisfy a static func f() -> Self requirement "smells" a bit off to me. I'm not sure what technical complexity or potential adverse side effects it might introduce, but I think it definitely complicates the user model if we allow this:

protocol P {
  static func f() -> Self
}
enum E: P {
  case f
}

// This seems like an existential contradiction:
// - E is an enum, so E.f should refer to the case value,
//   i.e., a "static var". It's not callable.
// - E conforms to P, and P has a "static func f()" requirement,
//   so E should as well.
// - But E.f can't be both a var and a func.
E.f()

I think I prefer the principle of least astonishment here—we shouldn't try to wedge cases without associated values into function witnesses.

Maybe one day in the future, a public version of @_implements could be used to bridge the gap?

Agree, that’s why I called it out. I think requirements like this are the exception - usually they would be written as a get-only property requirement. That seems like the Swiftier approach in general.

This seems like a good path forward. In the meantime, writing the forwarding function isn’t that big a deal.

Big -1 from my point of view. This kind of syntactic sugars may be of convenience in some cases (and for some users). But besides adding language complexity to compiling tools, there will also be tremendous cognitive burden (why enum cases should be treated as protocol methods?) to code readers and reviewers, new beginners, programmers come from other languages.

Do we really need to have this kind of sugars? Can't we write code without these extra rules? More specifically, is there really a pain to make an enum manually conforms to a protocol, or can we change to another design pattern instead of writing similar enum cases as protocol methods (I've never written or read this kind of code in the motivation section before, nor would I recommend this pattern)?

I think people having hot discussion with fantastic ideas is a good thing. But when it comes to a formal evolution proposal, I would prefer that language enthusiasts could consider the feelings of more broader range of iOS/swift developers. I cannot deny that things like automatic Hashable conformances are of great help. But each time a syntactic sugar is considered, we should also ask ourselves to what degree could this kind of stuff improve the overall programming experience for most users.

In our daily programming, there will always be some repetitive work, more or less. There are tons of ways to solve these things. We could save code snippets, write separate class to unify the logic, change design patterns, write code generators. Adding a facility to the language should be the last thing considered. If one has many years of programming, he already has the knowledge of many different ways to solve(or circumvent) these problems. If a programming pattern emerges several times, should we choose to make these patterns (which others may not write or prefer) into syntactic sugars within the language first? I'd say some programmers (include me and my colleagues) prefer simple and easy-to-understand grammars and code, and copy-and-paste is not always a bad thing, as long as the compiler would give us a warning or error if we make a mistake. That's it.

4 Likes

That's why this is a pitch.

1 Like

I understand. Maybe the content of this post could be placed under using-swift sub forum, I think.

Does that mean we should rollback SE-0036? I don't think we can pretend enum cases are equivalent to static variables and functions while simultaneously requiring a leading dot that variables and functions don't require, can we?

3 Likes

We should not roll that back. (In my opinion, of course.)

1 Like

How would this actually play with this accepted behavior: https://github.com/apple/swift-evolution/blob/master/proposals/0155-normalize-enum-case-representation.md#alternative-payload-less-case-declaration

Would it mean that this is also not valid, or are reevaluating this now?

protocol P { static func foo() -> Self }
enum E: P { case foo() } // okay or not?

That exact behavior is what @anandabits and I were both referring to above:

case foo() is invalid in Swift today, so there's no way to construct a case whose type looks like a zero-argument static function. Even when the syntax case foo() was allowed, it was not equivalent to case foo but rather to case foo(()).

I don't think this proposal should change that behavior just to permit a possible protocol conformance to be wedged in a little easier. Allowing case foo() would mean having to use .foo() throughout any references to it or pattern matches involving it, and I don't see any clear benefit (only confusion) from letting users declare explicitly empty function-like cases. Today, the only place where an enum case would be written like .foo() is if all of its associated values were given default values.

2 Likes

I disagree with this. Enum cases already work like static vars and functions, and can be used like that everywhere. Except for protocol conformances. That, to me, came as a real surprise when I first learnt it, and still baffles me as inconsistent and strange.

The proposed change would make the mental model easier IMHO, as it would make the enum story more consistent, with fewer exceptions. It would ease the cognitive burden.

8 Likes