Extract Payload for enum cases having associated value

Yeah definitely. I pitched something similar as a means of disambiguation to appease folks that are worried about such a change affecting source compatibility in general. I for one would be happy to break source compatibility for confusing edge cases that are hopefully minimal and unlikely. Ideally we can measure such changes in the source compatibility suite!

4 Likes

That's the way the Swift compiler sees it. I'm thinking more about the view of going from a list of payload data to the enumeration type. To be consistent, a payload-less case would map to a zero-parameter function.

Yes. The empty case is special.

For me, exposing the types of static factory methods in the original post was a way to provide one definition of the "type of the payload" which does not quite well match the expected definition of the "type of the payload" for key paths. For case namedValues(a: Int, b: String), the factory method says (Int, String), when we want (a: Int, b: String) for key paths.

There is not only one single definition of "type of the payload", and I think it was important to reveal it as clearly as I could.

This would be gorgeous for my library. :smiley: Unfortunately... doesn't happen as of now. :cry:

1 Like

I don’t think we do.

Recall that we had that round of Evolution proposals about distinguishing parameter lists from tuples, which included changing argument labels to be regarded as part of the function name instead of the type (SE-0111). For instance f(a: Int, b: String) is now considered to have type (Int, String) -> Void and name f(a:b:).

And then recall that we had that Evolution proposal to align enum cases with functions (SE-0155). The idea is that case namedValues(a: Int, b: String) will be considered to have name namedValues(a:b:), and the payload is then (Int, String).

6 Likes

@xwu, I wrote "we want", but it was more a question of mine.

The letter of the law (the proposals you quote) do not talk about enum key paths, so I'm not sure they have a positive impact on the design of those. They only drive the types of the factory methods, which are not questioned in this thread.

The advantage of having the key path \.namedValues for namedValues(a: Int, b: String) expose a value of type (a: Int, b: String) is that it yields very naturally to composed key paths such as \.namedValues.a. It is DESIRE 2 in my original post), motivated a few lines above its definition.

Now, I've been guilty of confusion. The type of a factory method can not define a "type of payload", because it can have several arguments, like (Int, String) -> MyEnum. Function arguments are not tuples any more: we can't feed (Int, String) -> MyEnum with a single value of type (Int, String).

So why not (a: Int, b: String) for the key path?

Because you can have case foo(a: Int, a: Int, a: Int) but not (a: Int, a: Int, a: Int). (This is, again, per the final state of affairs as proposed and approved, which still isn’t yet fully implemented.)

Ha, yes, you're right!

So... If one really wants to write \.someCase.someMember key path, one needs to define a Struct (or class) that holds the payload:

 enum MyEnum {
-    case namedValues(a: Int, b: String)
+    case namedValues(Payload)
 }
+ struct Payload {
+    var a: Int
+    var b: String
+}

-enum[keyPath: \.namedValues.a] // Error: `a` is not a member of (Int, String)
+enum[keyPath: \.namedValues.a] // Int?

Aren't we making progress?

Edit: another option that we haven't studied yet is that some key paths are not generated:

enum MyEnum {
    case distinctNames(a: Int, b: String)
    case identicalNames(a: Int, a: String)
}

\MyEnum.distinctNames.a  // OK: KeyPath to (a: Int, b: String) tuple
\MyEnum.identicalNames.a // Compiler error

This leads me to think that maybe homonymous parameters should be disambiguated like in functions. I don't really know why this is possible in the first place.

If I do func f(a: Int, a: Int) I get the compiler error Definition conflicts with previous value

previous definition of 'a' is here
func b(a: Int, a: Int) {
       ^

It sounds fair that enum cases should follow the same rule... apologies if this was already discussed.

Discussed, but without any conclusion yet. Check @xwu's message above.

Edit: there is a conclusion, and it looks like it is implemented on master, according to the linked issue. @Slava_Pestov should know better: is case foo(a: Int, a: Int) about to be allowed?

Sure, but that is because a is already defined inside the function body. However, you are allowed to use the same external argument label more than once, as long as they are tied to separate variables inside the function:

func f(a: Int, a b: Int) {
  print(a, b)
}

f(a: 1, a: 2) // prints "1 2"
1 Like

I'm aware of that, but you can't do it with enum cases. In vision of KeyPaths it might be good to introduce external argument (I don't like it) or limit names to be unique.

I think I've seen several core members say that these kinds of changes would have to go through evolution again if implemented, at which point we could raise any incompatibilities.

IMHO, I'd like to deprecate the feature, and require all argument labels to be unique. Although source breaking, I expect that very few (if any!) are using this feature for anything.

2 Likes

I’m not aware of such a statement. What’s left to implement are a few edge cases and catching up with some technical debt; the overall model is already there.

This is entirely out of the question. For a long time now, argument labels have often been prepositions to clarify the relationship of arguments with each other at the use site; these absolutely can repeat, and this kind of use is reflected in the naming guidelines.

Enums associated types, as of SE-0155, have what are argument labels. What they do not have are internal parameter names. In fact, those labels are currently never used as variable names, since the only way to get the associated values requires a pattern binding that supplies your own variable names.

During review of SE-0155, the part of proposal to require variable names in a pattern binding to match the argument label, even if only as part of a convenience feature, was rejected as unprecedented in Swift.

If there are going to be key paths that retrieve all associated types as a tuple, it would be inconsistent with the current design of Swift to require argument labels to function like variable names (in the sense that, as tuple labels, they could be referred to as members of the tuple). Tuples in Swift have a feature that lets users add labels at will on assignment, so I fail to see why such an inconsistent treatment would be desirable in the first place.

2 Likes

I've been following the various threads around enum keypaths and wanted to get a sense of what is the appropriate next step for moving fwd in the evolution process as of now.

Is there a lack of consensus on some aspect of the enum keypath ergonomics? Implementation details? Or is it a matter of needing someone to provide a formal proposal with associated implementation?

If it were up to me, I'd overhaul enums completely. The model I want is this:

  enum MyEnum {
     case 
          foo(label:String, Int),
          bar(Double),
          baz
  } 

Usage

  let 
     a: MyEnum = .bar(123.0),
     cpy: MyEnum = a, // .bar(123.0) case and its payload
     val: Double = a() // 123.0 payload only

  let 
     b: MyEnum = .bar(123.0),
     c: MyEnum = .bar(456.0),
     d: MyEnum = .baz

   a == b // true (both are 'bar')
   a === b // true (both 'bar' and same payload)

   a == c // true
   a === c // false  (different payload)

   a == b // false
   a === b // false

I have essentially been using this model (via structs), and it works well. The trouble with using a struct is that the declaration of the struct involves a lot of code repetition (you have to declare both a static var and a static function overloaded with the same name for every 'case') and wasted storage for the backing id. Also, if it were built-in, Swift could warn you, when you, for example, try to compare two cases with payloads of different types.

When a is baz a() should return Void, therefore there is no way that val could be declared as Double. The type of an associated value in an enum is uncertain. All you know for a is that it is a MyEnum which is not enough to guarantee that val will be Double.

If I remember correctly, in this thread we didn't reach an agreement on the ergonomics. But I've bundled my solution in EnumKit and I've been using that happily since then in combination with Combine and RxSwift code. Until we get something in the language, this is so far all I need.

1 Like

Why "should" it work that way? It's a design choice (and simple to implement, thanks to "@dynamicCallable").

With the paradigm I like, the programmer doesn't call the enum value with parens, unless they want its payload. Since the Payload is stored as "Any", we need to specify its type when we retrieve it. I'd also be fine if calling via parens provides an optional:

val: Double? = a() // nil, unless the Payload type matches

In practice, I find this matches what I actually need to achieve when I use an enum with a payload. That's opposed to the way Swift works currently, which... I guess it works in theory? It certainly is unwieldy and strange, in practice, for nearly every situation where it is supposed to be useful.

Note that, the "struct as enum" system I have been using, does understand the payload type, when the programmer creates an enum — and, with some tweaks to Swift — the Swift compiler could use that information when retrieving the payload, too.

PS: I haven't checked out EnumKit yet. I suspect I also will find it far superior to what we currently have in Swift.

let a: MyEnum = .baz // No payload
let val: Double = a() // Not legit. `a()` can be Double only if `a` is `.bar(xyz)`

This is what I'm trying to say. you can't have an honest Double from a variable of type MyEnum because you can't know apriori if:

  1. There is an associated value to extract.
  2. The associated value is in fact of type Double and not (String, Int) for example if a was .foo.