Key Path Expressions as Functions

Introduction

This proposal introduces the ability to use the key path expression \Root.value wherever functions of (Root) -> Value are allowed.

Swift-evolution thread: Key Path Expressions as Functions

Previous discussions:

Motivation

One-off closures that traverse from a root type to a value are common in Swift. Consider the following User struct:

struct User {
    let email: String
    let isAdmin: Bool
}

Applying map allows the following code to gather an array of emails from a source user array:

users.map { $0.email }

Similarly, filter can collect an array of admins:

users.filter { $0.isAdmin }

These ad hoc closures are short and sweet but Swift already has a shorter and sweeter syntax that can describe this: key paths. The Swift forum has previously proposed adding map, flatMap, and compactMap overloads that accept key paths as input. Popular libraries define overloads of their own. Adding an overload per function, though, is a losing battle.

Proposed solution

Swift should allow \Root.value key path expressions wherever it allows (Root) -> Value functions:

users.map(\.email)

users.filter(\.isAdmin)

Detailed design

As implemented in apple/swift#19448, occurrences of \Root.value are implicitly converted to key path applications of { $0[keyPath: \Root.value] } wherever (Root) -> Value functions are expected. For example:

users.map(\.email)

Is equivalent to:

users.map { $0[keyPath: \User.email] }

The implementation is limited to key path literal expressions (for now), which means the following is not allowed:

let kp = \User.email // KeyPath<User, String>
users.map(kp)

:stop_sign: Cannot convert value of type 'WritableKeyPath<Person, String>' to expected argument type '(Person) throws -> String'

But the following is:

let f1: (User) -> String = \User.email
users.map(f1)

let f2: (User) -> String = \.email
users.map(f2)

let f3 = \User.email as (User) -> String
users.map(f3)

let f4 = \.email as (User) -> String
users.map(f4)

Any key path expression can be used where a function of the same shape is expected. A few more examples include:

// Multi-segment key paths
users.map(\.email.count)

// `self` key paths
[1, nil, 3, nil, 5].compactMap(\.self)

Effect on source compatibility, ABI stability, and API resilience

This is a purely additive change and has no impact.

Future direction

@callable

It was suggested in the proposal thread that a future direction in Swift would be to introduce a @callable mechanism or Callable protocol as a static equivalent of @dynamicCallable. Functions could be treated as the existential of types that are @callable, and KeyPath could be @callable to adopt the same functionality as this proposal. Such a change would be backwards-compatible with this proposal and does not need to block its implementation.

ExpressibleByKeyPathLiteral protocol

It was also suggested in the implementation's discussion that it might be appropriate to define an ExpressibleByKeyPathLiteral protocol, though discussion in the proposal thread questioned the limited utility of such a protocol.

Alternatives considered

^ prefix operator

The ^ prefix operator offers a common third party solution for many users:

prefix operator ^

prefix func ^ <Root, Value>(keyPath: KeyPath<Root, Value>) -> (Root) -> Value {
  return { root in root[keyPath: keyPath] }
}

users.map(^\.email)

users.filter(^\.isAdmin)

Although handy, it is less readable and less convenient than using key path syntax alone.

Accept KeyPath instead of literal expressions

There has been some concern expressed that accepting the literal syntax but not key paths may be confusing, though this behavior is in line with how other literals work, and the most general use case will be with literals, not key paths that are passed around. Accepting key paths directly would also be more limiting and prevent exploring the future directions of Callable or ExpressibleByKeyPathLiteral protocols.

93 Likes

That's huge. I can't wait for a positive outcome from this wonderful pitch. Kudos for the "Detailed design" section, which I find very sensible.

3 Likes

This would be so awesome! The point free style is beautiful! :smile:

1 Like

Looks good! Another future direction would be to introduce Callable (i.e. the static equivalent to DynamicCallable) and treat function types as the existential type of types that are Callable with a matching signature. Key paths would naturally be Callable and would therefore convert to the existential function type just any concrete type conforming to a protocol does with protocol existentials today.

10 Likes

Looks great. Is the error message quoted above final? A fixme or at least a suggestion to add the type explicitly would be better than saying "cannot convert".

1 Like

I believe the error is reusing existing diagnostics. I'm not sure a fix-it is possible, though I'm always on board with improved error messaging.

This looks like a simple but awesome proposal. Only comment: it might be worth showing examples of multiple-segment key paths in the proposals to show that it is possible and valid (I guess it is):

users.map(\.address.postalCode)

7 Likes

So I guess with this change I would also be able to write

let optArray: [Int?] = ...
let array: [Int] = optArray.compactMap(\.self)

and

func foo(_ closure: (String) -> Int = \.count) { ... }

Can you please clarify if the explicit type is always required

let f: (User) -> String = \User.email
users.map(f)

or if it can be inferred

let f: (User) -> String = \.email
users.map(f)
6 Likes

Or

let f: (_) -> _ = \User.email    
users.map(f)

BTW: +1 from me for the proposal

I think keypaths should accommodate methods. For an instance, if type User had a method named details, then it would be like this
user[keyPath: \User.details]() //this will call the method "details" on this instance "user"

1 Like

I would love it if Swift would automatically lift key paths stored in variables as well, but this is still a fantastic step forward, huge +1 from me!

So Swiftyyy! That's a really huge improvement.

I would also prefer a more generalised Callable thing. We already have something like it with the new string interpolation design, which even allows for one type to support multiple signatures and parameter labels.

Another thing is that I expect this will become the preferred method for 'invoking' (following?) keypaths. Personally I find the someVar[keyPath: path] subscript a bit awkward, and would probably always use path(someVar) instead. Would it be worth deprecating the existing syntax, or at least not teaching it anymore? You can't generally extend Any, so there is some special stuff in the compiler just for the keypath subscript IIRC.

1 Like

KeyPath literals are so structured, so inherently tied to static resolution, and so opque after construction that it’s hard to imagine what an ExpressibleByKeyPathLiteral protocol could be used for other than sugaring the wrapping of a KeyPath, which doesn’t seem to justify the protocol. The conversion direction seems the right way to go, especially if we move partially-applied methods to the same \... syntax, which several of us would like to do.

17 Likes

Yup. I'll flesh out some examples in the proposal when I find time.

It's not. let f: (User) -> String = \.email is inferred just fine. Will update this example, too.

Totally agree. I'll add to "Future Directions."

4 Likes

+1 for the proposal and the clarification.

This proposal looks great to me. The functionality proposed is useful and makes sense with the directions we had in mind from the beginning for the overall feature.

13 Likes

Without a use-case for keypath literals, having KeyPath conform to Callable seems the more appealing option.

With either approach, I'd like to see members on KeyPath for manual conversion to getter and setter functions. Even with implicit conversion or language features, we still tend to have manual alternatives:

  • Sequence.forEachfor
  • Optional.init ≈ implicit conversion
  • KeyPath.get{ $0[keyPath: keyPath] }
1 Like

I really like the way this pitch settles a bit of ambiguity that's existed since key paths were introduced — should APIs that need a getter overload for both key paths and closures, or just kind of ignore key paths as the standard library does? With this pitch, things are more clear:

  • If your function needs a getter, write it as a closure parameter
  • If your function needs read/write access to a property, write it as a writeable key path

This also dovetails nicely with the identity key path, as Adrian noted above.

7 Likes

I was wondering if this proposal is accepted that we can potentially extend a similar behavior to propery functions like get, set etc.

Would you consider this as a future direction?

public struct Something {
  private var storage: Storage = ...
  
  // overriding get/set functions
  public var property: Value {
    get(\.storage.value)
    set(\.storage.value)
  }

  // or unified version of get/set
  // not sure if `inout` would be correct
  public var property: Value {
    inout(\.storage.value)
  }
}
8 Likes