Exciting. I think all these concerns have already been mentioned, but:
There needs to be a way to disambiguate methods from properties with the same name
There needs to be a way to disambiguate methods from methods with the same names
There needs to be a way to disambiguate methods from methods with the same names and keywords, but different argument types
What's the difficulty with supporting throws and async? Seems like they'd just turn up in the closure type within the KeyPath<BaseType, () throws(E) async>
Seems like mutating methods on structs couldn't be supported, since the keypath's closure type doesn't include the self parameter & therefore can't encode any information about how self is used?
What I really want this for is to do something like dynamicMemberLookup but allow forwarding methods. At a glance it seems like that should "just work", but I'd be disappointed if it didn't.
Yes, mutating methods should be represented the same way as properties with mutating get are represented. Which currently is not represented at all:
struct S {
var k: Int = 0
var foo: Int {
mutating get {
k += 1
return 42
}
}
}
let kp = \S.foo // error: Key path cannot refer to 'foo', which has a mutating getter
If we want to add them to the hierarchy, then new class would need to be a superclass of the KeyPath, which I guess would be an ABI breaking change(?). On top of that, that would lead to diamond inheritance. This could be solved by reworking key path hierarchy into hierarchy of protocols, but again that's a huge API and ABI breaking change.
After working through the implementation, I am happy to report that we will be able to disambiguate between overloads with method names:
struct S {
var add: (Int, Int) -> Int { return { $0 + $1 } }
func add(this: Int) -> Int { this + this}
func add(that: Int) -> Int { that + that }
}
let kp1 = \S.add // KeyPath<S, (Int, Int) -> Int
let kp2 = \S.add(this:) // WritableKeyPath<S, (Int) -> Int>
let kp3 = \S.add(that:) // WritableKeyPath<S, (Int) -> Int>
let kp4 = \S.add(that: 1) // WritableKeyPath<S, Int>
Additionally, nonisolated, escaping and consuming will all be supported in this feature. mutating, throwing and async are not supported for any other component type and will similarly not be supported for methods.
Dynamic member lookup for method members is included as well. We will be able to write:
class C {
func subtract(this: Int) -> Int { this - this }
}
@dynamicMemberLookup
struct DynamicKeyPathWrapper<Root> {
var root: Root
subscript<Member>(dynamicMember keyPath: KeyPath<Root, Member>) -> Member {
root[keyPath: keyPath]
}
}
let dynamicC = DynamicKeyPathWrapper(root: C())
let subtract = dynamicC.subtract
print(subtract(10))
Component chaining between methods or from method to other keypath types is also supported with this feature and will continue to behave as Hashable/Equatable types.
let kp5 = \S.add(this: 1).signum() // 1
let kp6 = \S.add(this: 2).description // "4"
class NonEquatableClass {}
struct BaseType {
func foo(_ c: NonEquatableClass) {}
}
let instance = NonEquatableClass()
let first = \BaseType.foo(instance)
let second = \BaseType.foo(instance)
let third = \BaseType.foo(.init())
print(first == second) // ?
print(first == third) // ?
Key paths cannot capture subscript indices that aren't Hashable or Equatable, and it seems like that should apply to captured method arguments as well.
Keypaths to generic methods will require specialization just like instance method references so only your concrete example, or a specialized example will work:
protocol Input {
associatedtype Output
}
extension Int: Input {
typealias Output = String
}
struct Foo {
func transform<T>(_ in: T) -> T.Output where T: Input {}
@_specialize(where T == Int)
func transformSpecialized<T>(_ in: T) -> T.Output where T: Input {}
}
let instance1: <T: Input>(T) -> T.Output = Foo().transform ❌ // "Generic parameter T cannot be inferred"
let instance2: (Int) -> String = Foo().transform<Int> ❌ // "Generic parameter T cannot be inferred"
let instance3: (Int) -> String = Foo().transform
let instance3Specialized: (Int) -> String = Foo().transformSpecialized
let kp: KeyPath<Foo, (Int) -> String> = \.transform
let kpSpecialized: KeyPath<Foo, (Int) -> String> = \.transformSpecialized
Keypath references to methods will follow instance method references patterns.
I think the basic idea here is fine, but I'm concerned about allowing key paths for methods to be written with just their base name (\.methodBaseName). I know this is something we allow with normal function and method references, but I've always considered that a misfeature; over the years, it's created a lot of confusion, bad diagnostics, and source compatibility problems for libraries. I would really prefer not to introduce a new use of it, especially if it's going to immediately become load-bearing for dynamic member lookup.
Also, with function and method references, the source compatibility problems can at least be resolved on the client side by just using a closure expression. That doesn't work here because it wouldn't build a key path; the client has to declare a new, unambiguously-named method to use in the key path literal. The client could also provide a parameter list after the base name... but not if the function is nullary, because it would look exactly like a call.
Could you give some recent examples of "confusion, bad diagnostics, and source compatibility problems for libraries"? My understanding is that the typechecker will always prefer properties over unapplied method references and that the standard way of disambiguating in favor of a method is to use its compound name, but I think seeing some examples of where either fails would be helpful.
The preference for properties over unapplied method references means you can add a method without breaking existing property references. You cannot add a property without breaking existing methods, though, or add a second method with the same base name.
The confusion and bad diagnostics arise from making obvious-looking code do non-obvious things. People writing something that looks like a property reference are usually not intending to write a method partial application, but because that's a permissible thing to do, the compiler has to assume that's what you meant.
I started thinking about how the compiler and stdlib could implement new ExpressibleBy protocols to make it possible to disambiguate using as (e.g. \.foo as ProtocolKeyPath), but I realized this solution doesn’t work if the overloaded key is in the middle of a key path: \.foo.bar is still ambiguous if foo names both a property and a method that both return a type with a property or method named .bar.
A truly workable solution might require a revamp of keypath syntax that allows explicit disambiguation of each key. It turns out DocC has already had to solve a very similar problem, using a syntax like foo-property which cannot be confused with a valid Swift property name. Unfortunately that would require adding logic to the parser so that \.foo-property is parsed as a keypath expression rather than an invocation of the - operator on a KeyPath and whatever property resolves to. Though it’s unlikely someone would define a func -<T, U>(lhs: KeyPath<T>, rhs: U).
I know there's the syntax function(_:) for unapplied references of functions with arguments, but how would you distinguish unapplied references vs calls of functions with no arguments? I've always done function for the former and function() for the latter, but it sounds like you're saying omitting the parens shouldn't be supported.
I'm saying I don't think bare function names should be allowed in key paths, yes. I do think they should probably also be disallowed in member expressions, but that's not currently under discussion.
In theory, the same ambiguity and source evolution problems could arise with bare identifiers, but it seems to not happen in practice. You can't have a conflict if the name resolves locally (at least, not a surprising one), so it would have to resolve globally (or be an implicit member reference, which I would bundle into the rule above), and that's unlikely to conflict because, in my experience, Swift programmers tend to avoid global-scope variables and functions in the first place. Operators are the main exception, and they don't conflict because they can't be easily used as variable names and tend to have fixed arity.