[Pitch] Generalize keypath-to-function conversions

Hi everyone!

Wanted to write up a proper pitch for this feature proposal. Still not sure if this requires a full round of evolution or not (since it is debatably covered by the accepted language of SE-0249), but it's a small enough addition that I felt it couldn't hurt to be a bit more precise. The latest version of this text can be found at the swift-evolution PR.

Introduction

Allow key path literals to partake in the full generality of function-function conversions, so that the following code compiles without error:

let _: (String) -> Int? = \.count

Motivation

SE-0249 introduced a conversion between key path literals and function types, which allowed users to write code like the following:

let strings = ["Hello", "world", "!"]
let counts = strings.map(\.count) // [5, 5, 1]

However, SE-0249 does not quite live up to its promise of allowing the equivalent key path construction "wherever it allows (Root) -> Value functions." Function types permit conversions that are covariant in the result type and contravariant in the parameter types, but key path literals require exact type matches. This can lead to some potentially confusing behavior from the compiler:

struct S {
  var x: Int
}

// All of the following are okay...
let f1: (S) -> Int = \.x
let f2: (S) -> Int? = f1
let f3: (S) -> Int? = { $0.x }
let f4: (S) -> Int? = { kp in { root in root[keyPath: kp] } }(\S.x)
let f5: (S) -> Int? = \.x as (S) -> Int

// But the direct conversion fails!
let f6: (S) -> Int? = \.x // <------------------- Error!

Proposed solution

Allow key path literals to be converted freely in the same manner as functions are converted today. This would allow the definition f6 above to compile without error, in addition to allowing constructions like:

class Base {
  var derived: Derived { Derived() }
}
class Derived: Base {}
let g1: (Derived) -> Base = \Base.derived

Detailed design

Rather than permitting a key path literal with root type Root and value type Value to only be converted to a function type (Root) -> Value, key path literals will be permitted to be converted to any function type which (Root) -> Value may be converted to.

The actual key-path-to-function conversion transformation proceeds exactly as before, generating code with the following semantics (adapting an example from SE-0249):

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

Source compatibility

This proposal only makes previously invalid code valid and does not have any source compatibility implications.

Effect on ABI stability

N/A

Effect on API resilience

N/A

Acknowledgements

Thanks to @ChrisOffner for kicking off this discussion on the forums to point out the inconsistency here.

32 Likes

Would this fix SR-12897 or is it a total unrelated thing?

It helps bridge the gap and consolidate the mental model for key paths as functions. Straightforward. Solid +1.

It looks like this might not be strictly related based on the error we get if we specify the root type explicitly:

let x: [Int] = [1, nil, 3, nil, 5].compactMap(\Int?.self) 
// Error: key path value type 'Int?' cannot be converted to contextual type 'Int?'

The above should compile regardless of this proposal or not (unless I'm missing something...).

But its possible that a fix would naturally fall out of the implementation of this feature!

1 Like

What happens in these cases?

func evil1<T>(_: T) {}
func evil1(_ x: (String) -> Bool?) { print("aha") }

evil1(\String.isEmpty)

func evil2<T>(_: T?) {}
func evil2(_ x: (String) -> Bool?) { print("aha") }

evil2(\String.isEmpty)

func evil3<T, U>(_: (T) -> U) {}
func evil3(_ x: (String) -> Bool?) { print("aha") }

evil3(\String.isEmpty)
1 Like

This proposal wouldn't modify the rule adopted by SE-0249:

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...

so anywhere that currently infers the type of a key path literal as KeyPath<_, _> would, under this proposal, continue to compile without any difference.

To avoid changing the behavior of existing code, I'd say this should continue to call the generic version of evil3, as it would today. I'll make sure to include a test case to verify this, though!

In that case I’ll give you one more:

func evil4<T, U>(_: ((T) -> U)?) {}
func evil4(_ x: (String) -> Bool?) { print("aha") }

evil4(\String.isEmpty)
1 Like

Mercifully, the call to evil4 doesn't compile today (though perhaps it should?), so we have a bit more leeway. In the case of ambiguity between two function-accepting overloads, I'd like for the rule to be "we'll call whichever would be called with the equivalent closure construction":

func evil4<T, U>(_: ((T) -> U)?) {}
func evil4(_ x: (String) -> Bool?) { print("aha") }

evil4({ kp in { $0[keyPath: kp] } }(\String.isEmpty)) // Prints 'aha'

Sadly, this doesn't work for evil3:

func evil3<T, U>(_: (T) -> U) {}
func evil3(_ x: (String) -> Bool?) { print("aha") }

evil3({ kp in { $0[keyPath: kp] } }(\String.isEmpty)) // Prints 'aha'

This is actually a bug in the implementation of SE-0249 which promises that the keypath version will have the same semantics as the closure version, but if we want full compatibility we'll have to allow it. :confused:

Though, FWIW, the behavior for evil3 is already somewhat erratic when it comes to overload resolution (note: all we’re changing here is the type annotation of the closure, which in the first three cases doesn’t affect the type of the expression at all):

// Doesn't print 'aha'
evil3({ kp in { (string: String) -> Bool in string[keyPath: kp] } }(\String.isEmpty))

// Prints 'aha'
evil3({ kp in { string -> Bool in string[keyPath: kp] } }(\String.isEmpty))

// Doesn't print 'aha'
evil3({ kp in { (string: String) in string[keyPath: kp] } }(\String.isEmpty))

// Prints 'aha'
evil3({ kp in { $0[keyPath: kp] } }(\String.isEmpty))
3 Likes

Although there's an element of judgment involved where compatibility breaks of any kind happen, we're allowed to fix implementation bugs and not required to carve out new rules to immortalize them...

5 Likes

Didn't manage to land this two years ago, but picked the implementation PR back up to get it up to date in an attempt to wrap things up!

Notably, after a bit of fiddling and further investigation I am no longer troubled by the type of example that @jrose called out above:

Because we count the keypath-function conversion as an extra function conversion on top of any conversions that already have to happen, any additional solutions which this proposal would admit should end up with a higher function conversion score than existing solutions (which must necessarily be exact matches).

Granted, I don't think I have exhaustively explored all the possible more pathological cases which might trip this up, so I welcome any additional compatibility cases that folks want to check on, but as of right now I am feeling better about the compatibility story than I was previously.

Additionally, the incongruity I noted here (previously thinking we'd want this to match the generic overload, not the concrete overload):

can I believe be entirely explained by the fact that the keypath expression as written fails to propagate the String parameter type through the closure expression, thereby preventing any solution being found with the generic overload (since the generic function doesn't supply type context of its own).

Should be able to send this through a (hopefully) uncontroversial review soon, but if anyone has additional feedback before then would love to hear it!

ETA: the current version of the proposal text is not much changed from two years ago, but has gone through some minor updates and can still be viewed at the same PR.

6 Likes