Key Path Expressions as Functions

I have found a use for ExpressibleByKeyPathLiteral. It is a little complicated to explain, but I’ll try to summarize.

Let’s say we have some data, and we want to sort it first by one property, then another, and so forth. We could write a variadic sort function to do so, and at the call-side it might look like this:

var pets: [Animal] = ...

pets.sort(\.age, \.weight, \.name)

But we can’t make that work today since we don’t have variadic generics.

What we can do, however, is this:

pets.sort(.by(\.age), .by(\.weight), .by(\.name))

I have written a proof-of-concept implementation, and it works. Indeed, it works generically for any type, and variadically for any number of keypaths, and it works in Swift today even without variadic generics.

However, it would be nice to be able to just write the keypath literal, rather than having to wrap it in .by(). Thus, I have a non-trivial use-case for ExpressibleByKeyPathLiteral.

11 Likes

Ah, interesting. So that's still just sugaring the wrapping of a KeyPath, but it does make it clearer why that might be useful.

3 Likes

Yes. Moreover, even if we eventually do gain variadic generics, it will still be useful.

If we just had variadic generics then you could use all keypaths in the sort call, but there would be no way to specify which ones should be put in descending order.

On the other hand, my example implementation allows you to specify the sorting predicate, eg. .by(\.age, >). Thus with ExpressibleByKeyPathLiteral you could mix-and-match using .by() when you need to change the sort order, and keypath literals for the rest.

4 Likes

I just realized that this proposal actually yells for re-evaluation of the inclusion of isFalse to Bool, because a negated value of a boolean is not accessible right now.

users.filter(\.isAdmin.isFalse)
2 Likes

Or, more general, for addition of the function composition operator to enable

users.filter(not • \.isAdmin)

Why wouldn't we simply create an appropriate overload of prefix ! to work as expected?

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

[0.0, 1.0, 2.0].filter(!\.isZero)
6 Likes

Wouldn't you want the overload to work with (Root) -> Bool instead?

prefix func ! <A>(predicate: (A) -> Bool) -> (A) -> Bool {
  return { !predicate($0) }
}

[0.0, 1.0, 2.0].filter(!\.isZero)

And then you get into the question of which overloads you want on functions in general. Why not &&, ||, etc.?

1 Like

Sure; my example works in Swift today for key paths, whereas yours would require the feature pitched here.

Maybe I just don't understand your suggestion. Are you saying that adding a ! overload to KeyPath is preferable to a more general solution on functions?

Obviously that would also work and I have thought about it as well, but I prefer a more readable solution over the visually terse (!\.. Also it‘s not the key-path that you are negating, but the extracted value in the closure, while it reads the other way around.

Furthermore you could use isFalse in not function related key-paths (! is there too, but it‘s similar to toggle visually far apart and hard to spot.)

// with the operator
view.isHidden = !viewController[keyPath: \.view.isEnabled]

// using `isFalse`
view.isHidden = viewController[keyPath: \.view.isEnabled.isFalse]

My preference would be the direct type member over the operator for clarity and because it‘s followed direct after the boolean itself. You could have a more complex keypath expression where the boolean value you want to extract is nested further down the chain. The distance for isFalse would still remain one while the operator would distance itself from the boolean with each additional step the chain requires and loosen up both readability and clarity IMO.

Feel free to correct me, but I tend to say that in Swift we wanted to avoid overloading operators as much as possible, which would also speak against the extra overload here.

1 Like

edit: didn't read. Forget the answer.

It never stops. What about force-unwrapping optionals?

players.map(\.email!) // [String]

Maybe, maybe, we should remember that this pitch, as insanely lovely as it is, is not a silver bullet, and that closures are still our friends:

players.map { $0.email! }
[0.0, 1.0, 2.0].filter { !$0.isZero }

After all, that's what people do in Ruby:

values.select(&:is_foo)         # sugar
values.select { |v| !v.is_foo } # no sugar
4 Likes

You can already do this.

\[Int].first  // KeyPath<[Int], Int?>
\[Int].first! // KeyPath<[Int], Int>

Alternately:

players.map(\.email!)
[0.0, 1.0, 2.0].filter(! <<< \.isZero)

Wouldn't it be nice if:

  1. Enums had first-class support for key paths.
  2. Bool were simply enum Bool { case true, false }

That's all you'd need for \.view.isEnabled.isFalse to be derived automatically ;)

8 Likes

That is something I also want to answer the question regarding Optional, [nil, 42].count(where: \.isNone). However I'm not sure if we can swap the representation of a Bool from being a struct to an enum. If we could, then sure I'd support that change.

Wow, I didn't know about the force-unwrapping of keyPaths. That's pretty cool.

I just meant that those extensions may belong to user code, not to the standard library. The pitch is already good without this sugar.

To be clear, I wasn't saying that something like isFalse should be included into this proposal. Instead I think it's a reasonable thing to consider after this proposal was accepted, especially because operations with a predicate would miss the opposite state when used key-pathes as a simple machinery for reading values. Other than that, this proposal is just perfect.

I also do not like is isFalse. It leads to ambiguities like this:

struct Email {
  let isFalse: Bool
}
struct User {
  let email: Email
}
let user: User? = ...
user.flatMap(\.email.isFalse)

However, I think the proposal should be a welcome change. Anyone looking to be the implementer?

There is no ambiguity in your example.

Does this invert (User) -> Email or is it accessing isFalse? I chose a bad example here, but there could be cases in which isFalse could eater mean a property or the key-path-inversion syntax. I am for !. Sorry for the confusion.