Skip to content
Permalink
Branch: master
Find file Copy path
Find file Copy path
5 contributors

Users who have contributed to this file

@benrimmington @natecook1000 @krilnon @brentdax @airspeedswift
179 lines (116 sloc) 7 KB

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)

🛑 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)

Precise semantics

(Note: Added after acceptance to clarify the proposed behavior.)

When inferring the type of a key path literal expression like \Root.value, the type checker will prefer KeyPath<Root, Value> or one of its subtypes, but will also allow (Root) -> Value. If it chooses (Root) -> Value, the compiler will generate a closure with semantics equivalent to capturing the key path and applying it to the Root argument. For example:

// You write this:
let f: (User) -> String = \User.email

// The compiler generates something like this:
let f: (User) -> String = { kp in { root in root[keyPath: kp] } }(\User.email)

The compiler may generate any code that has the same semantics as this example; it might not even use a key path at all.

Any side effects of the key path expression are evaluated when the closure is formed, not when it is called. In particular, if the key path contains subscripts, their arguments are evaluated once, when the closure is formed:

var nextIndex = 0
func makeIndex() -> Int {
  defer { nextIndex += 1 }
  return nextIndex
}

let getFirst = \Array<Int>.[makeIndex()]     // Calls makeIndex(), gets 0, forms \Array<Int>.[0]
let getSecond = \Array<Int>.[makeIndex()]    // Calls makeIndex(), gets 1, forms \Array<Int>.[1]

assert(getFirst([1, 2, 3]) == 1)             // No matter how many times
assert(getFirst([1, 2, 3]) == 1)             // you call getFirst(),
assert(getFirst([1, 2, 3]) == 1)             // it always returns root[0].

assert(getSecond([1, 2, 3]) == 2)            // No matter how many times
assert(getSecond([1, 2, 3]) == 2)            // you call getSecond(),
assert(getSecond([1, 2, 3]) == 2)            // it always returns root[1].

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.

You can’t perform that action at this time.