Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Enum Case KeyPaths #58940

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open

Implement Enum Case KeyPaths #58940

wants to merge 3 commits into from

Conversation

Azoy
Copy link
Contributor

@Azoy Azoy commented May 17, 2022

Aka case paths, implement payload case key paths that will optionally return the tuple of a case's associated value. If the value of type enum is not the same case as the key path, nil will be returned. For example:

enum Color {
  case generic(String)
  case blue
}

let genericKp = \Color.generic
let blue = Color.blue

print(blue[keyPath: genericKp]) // nil

let pink = Color.generic("Pink")

print(pink[keyPath: genericKp]) // Optional("Pink")

Because tuple element key paths are already implemented, accessing different members of the associated value is really easy.

enum Something {
  case here(String, Int)
}

let hereIntKp = \Something.here?.1
let thing = Something.here("Hello", 123)

print(thing[keyPath: hereIntKp]) // Optional(123)

@Azoy Azoy requested a review from jckarter May 17, 2022 02:18
@Azoy
Copy link
Contributor Author

Azoy commented May 17, 2022

@swift-ci please test

@Azoy
Copy link
Contributor Author

Azoy commented May 17, 2022

@swift-ci please test

@CodaFi
Copy link
Contributor

CodaFi commented May 18, 2022

What does this do in the presence of an enum case overloaded by base name?

@Azoy
Copy link
Contributor Author

Azoy commented May 18, 2022

It kind of just works

enum Color {
  case generic(Int)
  case generic(thing: String)
  case generic(Int, String)
}

let x = \Color.generic // ambiguous, obviously
let y = \Color.generic(_:) // refers to generic(Int)
let z = \Color.generic(_:_:) // refers to generic(Int, String)
let a = \Color.generic(thing:) // refers to generic(thing: String)

I'll add lots more tests exercising this and other oddities.

@Azoy
Copy link
Contributor Author

Azoy commented May 18, 2022

@swift-ci please test macOS

@Azoy
Copy link
Contributor Author

Azoy commented May 18, 2022

@swift-ci please test Windows

@tgrapperon
Copy link

tgrapperon commented May 18, 2022

This is fantastic! This is only read-only, right?

var genericColor = Color.generic("#FFF")
var blueColor = Color.blue

genericColor[\.generic] = "#000" -> genericColor == .generic("#000")
blueColor[\.generic] = "#000" -> crash, like force-unwrapping Optional.none or accessing some collection out of bounds

Would this be reaching too far for this PR, or would this need to go through Swift Evolution regarding the behavior for the last case?

@Azoy
Copy link
Contributor Author

Azoy commented May 18, 2022

Right, these are read only right now so trying to use the subscript to mutate will be a compilation error (there wouldn't be a crash at runtime)

@tgrapperon
Copy link

@Azoy Thanks for your response. I'm understanding that it won't compile with the current state of this PR, but I was imagining the shape of a read-write API. After thinking more about it, I think crashes at runtime when setting the value of the "wrong" KeyPath are avoidable:

enum Color {
  case blue
  case generic(String)
  case white(Float, Float)
}

var myBlue = Color.blue
var myGeneric = Color.generic("#FFF")
var myGray = Color.white(0.5, 1.0)

myBlue[\.blue] = () => myBlue = .blue (Symmetric to the "get" part)
myBlue[\.generic] = "#000" => NOOP
myBlue[\.white?.0] = 0.6 => NOOP
myBlue[\.white] = (0.6, 0.0) => NOOP

myGeneric[\.blue] = () => NOOP
myGeneric[\.generic] = "#000" => myGeneric == .generic("#000") (Symmetric to the "get" part)
myGeneric[\.white?.0] = 0.6 => NOOP
myGeneric[\.white] = (0.6, 0.0) => NOOP

myGray[\.blue] = () => NOOP
myGray[\.generic] = "#000" => NOOP
myGray[\.white?.0] = 0.6 => myGray == .white(0.6, 1.0) (Symmetric to the "get" part)
myGray[\.white] = (0.6, 0.0) => myGray == .white(0.6, 0.0) (Symmetric to the "get" part)

In this case, it would work as one would most likely expect, and it would crash only when we force unwrap the chained cases \.white!.0 instead of using optional chaining. When we have all required associated values, we have enough information to switch the case to the one from the KeyPath, but we could also argue this should be no-op too:

myGeneric[\.white] = (0.6, 0.0) => | myGeneric == .white(0.6, 0.0) (Flip)
                                or | myGeneric == .generic("#FFF") (NOOP)

That's the part that is still open.

I understand it goes beyond the scope of this PR. This would however be very useful to derive bindings involving an enum in SwiftUI (as they're often relying on @dynamicMemberLookup using KeyPaths), or to a act as a complete replacement to swift-case-paths.

But this is already a very welcome feature! Thank you very much!

@jckarter
Copy link
Contributor

This is great, thanks @Azoy! A few things:

  • Can we represent case paths in a backward-deployable way? It would be great if they could still show up as read-only KeyPath<Enum, Payload?> to older runtimes. Maybe in addition to introducing a new pattern representation for CasePath, we could also encode them as get-only ComputedKeyPath components in a way that old runtimes will instantiate as a read-only computed key path, but which a new runtime can still recognize as a CasePath and instantiate as such.
  • Should \Optional.some canonicalize to an identity key path, so that things like \.foo.some?.bar and \.foo?.bar are treated as equivalent?

@tgrapperon We will most likely need to introduce new subclasses of KeyPath to represent optionally writable key paths. The set operation for those subclasses would then be able to throw (or express failure to set in some other way). In a future runtime that has those subclasses, we should be able to start instantiating the same case path pattern as a conditionally writable case path.

@slavapestov
Copy link
Contributor

@Azoy What about cases without payloads? The case value could be represented as a Bool, or Optional<()>.

@xwu
Copy link
Collaborator

xwu commented Jun 29, 2022

The case value could be represented as a Bool, or Optional<()>.

Hmm, case foo(()) is considered distinct from case foo, so the latter may be a little inconsistent in that it would be walking back from that...

Bool might just be the most explainable/usable, although I wonder if there's some other representation that's an option.

@stephencelis
Copy link
Contributor

Hmm, case foo(()) is considered distinct from case foo, so the latter may be a little inconsistent in that it would be walking back from that...

I'm not sure if there's much of an arguable difference, though, or precedent for needing to distinguish between the two. (I imagine foo(Void) being rare outside of generic contexts and inside generic contexts I think the proposed behavior makes sense.)

Case names can also no longer be overloaded, so no need to worry about overlapping case paths to a Void payload vs. a non-existent one.

Bool might just be the most explainable/usable, although I wonder if there's some other representation that's an option.

Definitely seems like Bool would be nice sugar for cases without associated payloads.

@jckarter
Copy link
Contributor

jckarter commented Jul 1, 2022

Should we start a pitch thread on the forums to start discussing design points like these?

@stephencelis
Copy link
Contributor

@jckarter While our library use of case paths has given us plenty of time and experience with the problem space, we’ve held off on writing any kind of pitch because we haven’t had any engineers familiar enough with the compiler available to help with the implementation. If @Azoy (or someone else with the knowhow) would like to work on a pitch with us, please let us know!

@Azoy
Copy link
Contributor Author

Azoy commented Jul 1, 2022

I think a pitch thread is a great idea. I'd be happy to work with you and others on a pitch @stephencelis.

@jckarter
Copy link
Contributor

jckarter commented Jul 1, 2022

@stephencelis @Azoy I don't think the technical aspects need to be 100% nailed down to start a pitch. It'd be good to share your experience and figure out how we can best integrate the functionality into the core key path implementation.

@stephencelis
Copy link
Contributor

@jckarter @Azoy Sounds good! @mbrandonw and I will try to get a draft together soon.

@Azoy Azoy changed the title Implement Payload Case KeyPaths Implement Enum Case KeyPaths Jul 10, 2022
@Azoy
Copy link
Contributor Author

Azoy commented Jul 10, 2022

@swift-ci please build toolchain

@Azoy
Copy link
Contributor Author

Azoy commented Jul 11, 2022

@swift-ci please build toolchain

@Azoy
Copy link
Contributor Author

Azoy commented Sep 15, 2022

@swift-ci please build toolchain

auto varDecl = cast<VarDecl>(decl);
// Key paths don't work with mutating-get properties.
assert(!varDecl->isGetterMutating());
// Key paths don't currently support static members.
Copy link
Contributor

@tevelee tevelee Sep 15, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Key paths don't currently support static members.
Can't we lift this limitation too?

enum Enum {
    case case1
    static let case2 = case1
}
let kp1 = \Enum.case1 // now supported
let kp2 = \Enum.case2 // Key path cannot refer to static member 'case2'

KeyPath for a case is very similar to a static member, like enum cases can stand as witness to static protocol requirements?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't looked into static members at all yet, but my initial reaction is that they are a little different from enum cases from an implementation point of view.

@Azoy
Copy link
Contributor Author

Azoy commented Sep 19, 2022

@swift-ci please build toolchain

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants