Why can’t key paths refer to instance methods?

This appears to be the stab in question: https://github.com/brentdax/swift/tree/i-am-the-keymaster

Judging by the last commit on the branch, his implementation wasn’t compatible with Swift 5.0 (the contemporary version). It worked on the Swift 5.1 beta, though.

2 Likes

This would be quite fantastic to get going, and we've been dreaming up some use-cases for this a while ago.

What I would be most interested in is ensuring that it is also possible to capture arguments in a KeyPath (slowly leading towards not only wrapping properties, but any function really via a kind of "function wrapper" which would kind of look the same -- get a KeyPath representing the invocation)...

1 Like

Assuming you mean argument labels, that seems way out of scope for this. I agree that it would be helpful, but that isn’t actually part of a function’s type right now.

Key paths to methods would handle arguments the same way subscript key paths handle indexes, by capturing them into the KeyPath object.

4 Likes

I still hope that we could somehow workaround the requirement of every parameter being hashable for key-path methods.

2 Likes

This one is on my wish list, because Siesta has a compelling use case for it. A common mistake when using Siesta is to do this:

resource.addObserver(owner: self, observer: self.fooChanged)

This innocent code creates a retain cycle under Siesta’s memory rules. No need to understand the context or fiddly details here; the fundamental problem is that self.fooChanged retains self, and the way Siesta works, we don’t want that.

Folks have suggested weak closures and/or weak method references (e.g. here, here), but another alternative that requires no new syntax and little new discussion — just the proposal at hand here — would be to alter Siesta’s observer API to use a keypath:

resource.addObserver(owner: self, observer: \.fooChanged)  // keypath doesn’t retain self!

Since the idea being pitched in this thread sounds feasible, I’d be happy to help write it up as a formal proposal.

4 Likes

Did you see @Joe_Groff’s comment:

If methods in key paths work like read-only subscripts, all arguments would need to be bound. Only self Is not bound. I looked at the siesta docs and it looks like the callback accepts two arguments that must be provided by the caller and neither appear to be the observer’s self which is what you would expect to pass to a key path.

It would be great if we could extend the read only subscript model somehow to support unbound methods where parameters are unbound in addition to self, or perhaps even allowing arbitrary “holes” in the path (including subscript parameters). This works with key path syntax. It might be possible to make it work with the KeyPath type as well by making Root a tuple, but I’m not sure that’s a good idea.

I didn’t get that from Joe’s post. @Joe_Groff, is that accurate? It would seem to severely limit the usefulness of this feature, and I don’t see any reason in principle it shouldn’t be allowed.

I would think this, for example, ought to work:

func munge(using keyPath: KeyPath<String, (Int) -> Substring>) -> Substring
    "noodles"[keyPath: keyPath](6)
}

munge(using: \.dropFirst)  // → "s"
munge(using: \.dropLast)   // → "n"
munge(using: \.prefix)     // → "noodle"
munge(using: \.suffix)     // → "oodles"

Ahh, I can see why you thought that would work now. Maybe I misunderstood what Joe was getting at.

I was bummed that SE-0042 didn't make it in. I wonder if we could make unbound method key path literals compatible with uncurried function types. That would allow mutating methods to finally be available in unbound form.

1 Like

Unbound mutating methods would indeed be lovely, but they seem a much thornier design space.

Same goes for key paths with argument binding:

\Sequence.dropFirst(2)   // Should this work?

\Array.index(_:offsetBy:2)    // How about one argument bound?

Quite a can of worms there — not least the syntax! (I remember some past discussions going around in quite a few circles over the parallel question for unapplied methods.)


In contrast, the proposal at hand seems quite tractable.

One little hitch is key paths that reference generic methods:

struct Foo {
  func bar<T>(_ val: T) -> [T] { … }
}

\Foo.bar    // what is the type of this key path?

The type of \Foo.bar would (I think?) require generalized existentials:

some <T> KeyPath<Foo, (T) -> [T]>  // made-up syntax

…or some other way of introducing new type variables into the types of individual values in Swift.

It seems to me an initial proposal here would have to simply forbid accessing such methods via key path.


I found myself also worrying about key paths that come from conditional conformances:

\Sequence.joined

What is the type of this key path? I don't see that it's possible to express it in Swift as the language stands; it would similarly require generalized existentials with embedded where clauses:

some <T> KeyPath<T, (T.Element) -> T.Element>   // made-up syntax
  where T: Sequence, T.Element: StringProtocol

But then I realized that today even property key paths don't work unless the root type can be expressed as a bare type in Swift, e.g.:

\Collection.count  // protocol 'Collection' can only be used as a generic constraint because it has Self or associated type requirements
\Array.count       // error: generic parameter 'Element' could not be inferred
\Array<Int>.count  // allowed 

…so we can safely rule out such root types for function key paths as well.

2 Likes

I find the proposal really appealing. If this feature makes it to swift a new map method could be added to the standard library:

extension Array {
    // Ignore the implementation
    func map<NewValue>(_ keyPath: KeyPath<Element, () -> NewValue>) -> Array<NewValue> {
        map { element in
            element[keyPath: keyPath]()
        }
    }
}

that would allow this:

let array = ["Hello", "World!"]

let transformed = array.map(\.dropFirst) // ["ello", "orld!"]
3 Likes

I think blocking off thorny functionality is a decent approach when implementing such things, so long as we don’t design ourselves into a corner. In particular, the syntax should eventually support any functionality that we can think of now, assuming it doesn’t violate basic principles like type safety.

One of my favorite things about Swift is its orthogonality. Finding an exception to it is what prompted this discussion, in the hopes of whittling it down further.

I’m not so sure. If we introduce syntax for unbound method key paths it might be possible to allow them to be used in contexts SE-0042 would have supported:

func takesMutating(_: (inout Foo) -> Void {}
func takesMutatingWithArg(_: (inout Foo, Int) -> Void) {}

struct Foo {
   mutating func bar() { }
   mutating func baz(_: Int) { }
}

takesMutating(\Foo.bar)
takesMutatingWithArg(\Foo.baz)

// possibly even this:
takesMutating(\.bar)
takesMutatingWithArg(\.baz)

What about the above approach seems thorny? IIRC, @Joe_Groff has mentioned method key paths in the context of a path forward for SE-0042 in the past. Perhaps something like this is what he had in mind.

In any case, I think it’s really important that we think about this before pushing forward with a method key path proposal. Even if mutating methods are not supported now, I’d like to know how they fit in as a future direction. It would be a shame to design ourselves into a corner where they are difficult to add later. And it would also be a shame to need an entirely different mechanism in addition to the existing unbound methods and the proposed method key paths.

The first example there looks very straightforward if you use subscript key paths as a precedent.

The second example with partially supplied arguments is not as straightforward because it requires syntax for the “hole” and is less clear how it might correspond to the key path type. I think this would be interesting to explore but is less important and should probably be left as a future enhancement.

I’m a bit out of my league here, but that looks closer to higher-rank types than existentials to me. I’ve never seen higher-rank types discussed outside the context of function types before though so I’m not sure.

1 Like

I was thinking of unbound methods, not key paths. But…

…you make an excellent point! At first blush, key paths seem better suited for capturing mutating methods than unbound method references, since they solve the problem of what gets mutated:

let a = Foo().mutatingMethod   // if we use a, how do we get the mutated Foo?
let b = \Foo.mutatingMethod    // if we use b, we have to provide the Foo; problem solved

Yeah, I realized after I posted that "existential" is the wrong concept here: an existential always has a concrete type at runtime, whereas here T remains free until called. (Also, existentials would be “any” and not “some” in my fake syntax examples. :face_with_raised_eyebrow:)

Out of my league too, but I think you're right about it being rank-N types. My fake syntax should have been:

forall <T> KeyPath<Foo, (T) -> [T]>

…or perhaps a more Swift-like term than “forall.” (It’s ironic that Swift, so ML-like in so many ways, doesn’t even have rank 1 function types like 'a → 'a!)

In any case, since Foo().bar with no further context wouldn’t be allowed in my example, so it follows that \Foo.bar shouldn’t be either — and that rules out the problem altogether.

It's worth noting that Swift does currently allow Foo().bar in that example if there's enough context to make the type specific:

Foo().bar as (Int) -> [Int]             // these compile today
Foo().bar as (String) -> [String]

…so should these be allowed too?

\Foo.bar as KeyPath<Foo, (Int) -> [Int]>
\Foo.bar as KeyPath<Foo, (String) -> [String]>

By contract, AFAICT my second example (\Sequence.joined) would still be ruled out entirely in the Swift type world of today; there’s no way to sufficiently narrow the types at key path creation time if the root is Sequence.

I think there are two features here:

  • Allowing key paths to refer to methods, and
  • Allowing key path literal syntax to form closures

Allowing the latter doesn't necessarily require that we constrain the feature to methods that fit the model of key paths, so we could still use the syntax to allow for shorthand references to mutating methods, methods with unbound arguments, and things like that which wouldn't really fit the key path model. That was our original intent when adopting the syntax for key paths.

6 Likes

I think that mutating methods would be better expressed as such:

struct Foo {
    mutating func bar() { ... }
}

let keyPath = \Foo.bar // WritableKeyPath<Foo, () -> ()>

let closure: (inout Foo) -> () -> () = \Foo.bar

The reason why I propose (inout Foo) -> () -> () instead of (inout Foo) -> () is because if we add parameters to the method ‘bar’ things quickly get complicated. I believe that converting the former closure to the latter is what @anandabits is interested in, but I’m afraid it’s out of the scope of this proposal.

The reason unbound mutating methods are not available today is because method signatures are in curried form. This does not work because it would need to escape the inout self and it isn't possible to escape an inout.

That's what I was wondering. It sounds like a good approach to me. Thanks for clarifying!

1 Like

I'm concerned about the way dynamic member lookup will be handled if method key paths are added. Will an instance method be treated as a closure or will we handle instance methods differently?

struct Foo {
    func bar(a: Int) { ... }
}

@dynamicMemberLookup
struct FooWrapper {
    subscript<Value>(dynamicMember keyPath: KeyPath<Foo, Value>) { ... }
}

let wrapper = FooWrapper()
wrapper.bar(5) // Should instance methods be allowed?
               // If so, are argument labels going to be required
               // or is 'bar' just a closure of type: (Int) -> Void

I think parameter labels should be required if you want to pass any parameters.

1 Like