[Pitch] Method Key Paths

Hello everyone,

The following is a pitch for method key paths in the Swift language. We welcome any feedback or questions about this pitch!


[Pitch] Method Key Paths

Introduction

Swift key paths can be written to properties and subscripts. This proposal extends key path usage to include references to method members, such as instance and type methods, and initializers.

Motivation

Key paths to method members and their advantages have been explored in several discussions on the Swift forum, specifically to unapplied instance methods and to partially and applied methods. Extending key paths to include reference to methods and initializers and handling them similarly to properties and subscripts will unify instance and type member access for a more consistent API. Key path methods and initializers will also enjoy all of the benefits offered by existing key path component kinds, e.g. simplify code by abstracting away details of how these properties/subscripts/methods are modified/accessed/invoked, reusability via generic functions that accept key paths to methods as parameters, and supporting dynamic invocation while maintaining type safety.

Proposed solution

We propose the following usage:

struct Calculator {
  func square(of number: Int) -> Int {
    return number * number * multiplier
  }

  func cube(of number: Int) -> Int {
    return number * number * number * multiplier
  }

  init(multiplier: Int) {
    self.multiplier = multiplier
  }

  let multiplier: Int
}

// Key paths to Calculator methods
let squareKeyPath = \Calculator.square
let cubeKeyPath = \Calculator.cube

These key paths can then be invoked dynamically with a generic function:

  func invoke<T, U>(object: T, keyPath: KeyPath<T, (U) -> U>, param: U) -> U {
    return object[keyPath: keyPath](param)
  }

  let calc = Calculator(multiplier: 2)

  let squareResult = invoke(object: calc, keyPath: squareKeyPath, param: 3)
  let cubeResult = invoke(object: calc, keyPath: cubeKeyPath, param: 3)

Or used to dynamically create a new instance of Calculator:

  let initializerKeyPath = \Calculator.Type.init(multiplier: 5)

This proposed feature homogenizes the treatment of member declarations by extending the expressive power of key paths to method and initializer members.

Detailed design

Key path expressions can refer to instance methods, type methods and initializers and imitate the syntax of non-key path member references.

Key paths can reference methods in two forms:

  1. Without argument application: The key path represents the unapplied method signature.
  2. With argument application: The key path references the method with arguments already applied.

Continuing our Calculator example, we can write either:

let squareWithoutArgs: KeyPath<Calculator, (Int) -> Int> = \Calculator.square
let squareWithArgs: KeyPath<Calculator, Int> = \Calculator.square(of: 3)

If the member is a metatype (e.g., a static method, class method, initializer, or when referring to the type of an instance), you must explicitly include .Type in the key path root type.

struct Calculator {
  func add(_ a: Int, _ b: Int) -> Int {
    return a + b
  }
}

let calc = Calculator.self
let addKeyPath: KeyPath<Calculator.Type, (Calculator) -> (Int, Int) -> Int> = \Calculator.Type.add

Here, addKeyPath is a key path that references the add method of Calculator as a metatype member. The key path’s root type is Calculator.Type, and it resolves to an unapplied instance method: (Calculator) -> (Int, Int) -> Int. This represents a curried function where the first step binds an instance of Calculator, and the second step applies the method arguments.

Then this key path can be invoked dynamically:

let addFunction = calc[keyPath: addKeyPath]
let fullyApplied = addFunction(Calculator())(20, 30)

In this example, addFunction applies an instance of Calculator to the key path method. Then fullyApplied further applies the arguments (20, 30) to produce the final result.

ABI compatibility

This feature does not affect ABI compatibility.

Effect on source compatibility

This feature has no effect on source compatibility.

Implications on adoption

This feature is back-deployable but it requires emission of new (method descriptors) symbols for static methods.

The type-checker will prevent the creation of key paths to static methods for types originating from modules compiled with an older compiler that does not support this feature because the dynamic or static library produced by such a module will lack the necessary symbols.

Attempting to form a key path to a static method of a type from a module compiled with a compiler that doesn't yet support the feature will result in the following error with a note to help the developers:

error: cannot form a keypath to a static method <Method> of type <Type>
note: rebuild <Module> to enable the feature

Future directions

Effectful value types

Methods annotated with async, throws, escaping, or nonisolated are not supported by this proposal, but could be explored in a future proposal that extends key paths to work with effectful value types. This could include methods as well as properties and subscripts and allow the following:

struct S {
  func status() async throws -> String {
    try await Task.sleep(nanoseconds: 1_000_000_000)
    return "Async operation completed!"
  }

  func status() -> String {
    return "Non-async completed instantly!"
  }
}

let keypathToAsync: AsyncThrowsKeyPath<S, () async throws -> String> = \S.status
let keypathToSync: KeyPath<S, () -> String> = \S.status

let instance = S()

Task {
  let result = try await instance[keyPath: keypathToAsync]()
  print(result)
}
22 Likes

I really want this. Some initial questions:

  • I'd like to see something about mutating methods
  • What about methods that have the same parameter and return types, but differing label names? Eg func add(this: Int) -> Int and func add(that: Int) -> Int. How would their keypath representations differ?
  • The inclusion of initializers is fascinating, as is the partially applied methods. I'll need to think about the ramifications of that.
10 Likes

I pitched this here a while back. There is a proof of concept that you may find useful, though frankly it is of low quality (I am not a compiler engineer).

It also includes support for dynamic member lookup, which was my personal motivation to try to get this to work in the first place. There's some discussion of alternatives in the thread that might be useful to you, if that's your motivation for taking this work up.

Edit: just realized that this is already linked at the top of the pitch. My mistake for not reading carefully. I still think there is some value in looking at some of the extensions described there fwiw.

1 Like

What happens when Calculator has properties (stored or computed) that have the names square and/or cube? I assume this is just an ambiguous error? In my opinion, I think we need to abandon the name form of referencing functions and require the parenthesis with arguments labels so that there's no form of ambiguity.

12 Likes

This is misleading. The implementation that exists effectively turns subscripts into properties:

You can do

struct S {
  subscript(argument: Int) -> Int { argument }
}
let keyPath = \S.[1]
#expect(S()[keyPath: keyPath] == 1)

but you can't get a key path to something that accepts arguments.


While I like the pitch, it is incomplete.

  1. Subscripts will need a syntax to do what you're proposing for methods, because that doesn't exist yet.

It can't be the seemingly obvious one because that's already supported for default arguments, or lack of arguments:

struct S {
  subscript(argument: Int = 1) -> Int { argument }
  subscript() -> Void { () }
}

\.[] as KeyPath<S, Int>
\.[] as KeyPath<S, Void>
  1. This may be built into the proposal, but it is not explicit: methods also need the syntax that subscripts already have:
let keyPath = \Calculator.square(of: 2)
#expect(Calculator(multiplier: 1)[keyPath: keyPath] == 4)

What's the rationale for this over something in-between that includes argument names, but omits argument application, ala:

let squareWithArgName: KeyPath<Calculator, (Int) -> Int> = \Calculator.square(of:)

In the pitch's current form, initializing keypaths for the update(byAdding:) method below would result in ambiguity problems:

struct Calculator {
  mutating func update(byAdding number: Int) {
    value += number
  }

  mutating func update(bySubtracting number: Int) {
    value -= number
  }

  init(value: Int) {
    self.value = value
  }

  private(set) var value: Int
}

let updateByAddingKeypath = \Calculator.update // ERROR: WHO AM I?

Thank you for looking into doing this! This should be a natural fit with the existing key path implementation.

I don't think method descriptors are ever necessary. Unlike properties, there is no way to hide implementation details of a method aside from hiding or showing the method as a whole (for instance, a property can be outwardly get-only but have a private setter, and the descriptor is necessary for key path equivalence to instantiate the keypath with the right setter; but a method is inherently always get-only).

One thing I would ask is whether we need to support both unapplied and applied methods. Applied methods are straightforward to support internally as a form of get-only subscript keypath component. Although unapplied key path components could be modeled like a get-only property access, their representation would necessitate the need to represent application independently as a separate key path component. If most key path method references are fully-applied, and there is no need for unapplied references, the extra application step could lead to a less efficient representation.

4 Likes

+1, I'm super excited about this!


I am wondering why nonisolated attribute support was excluded from this proposal—is the thinking here to support methods annotated with it along with the rest of concurrency annotations?

3 Likes

This is an exiting feature that I'm really looking forward too!
I have a couple questions about some details:

  • How does that work with generics? E.g. can a KeyPath be formed to Array<>.append(contentsOf:) that is generic over some Sequence
  • How are overloads handled?
  • How are mutating methods handled?
  • How are consuming methods handled?
  • Does it work with @dynamicMemberLookup? How does a call to a mutating method look like in that case?

I hoped that @dynamicMemberLookup with method key paths could somehow preserve parameter names which would allow us to create proxy objects that are syntax wise indistinguishable from the real type. Swift's closures and tuples wouldn't allow that though.

4 Likes

mutating methods will be WriteableKeyPaths:

struct Calculator {
  var multiplier: Int
  mutating func updateValue(to newValue: Int) {
      self.multiplier = newValue
  }
}
let mutatingKP: WritableKeyPath<Calculator, (Int) -> ()> = \Calculator.updateValue

The key path root and value types will be the same for both, but if they're fully applied with no external parameter names (or if they're unapplied), there is no way to disambiguate between:

struct Calculator {
  func add(this: Int) -> Int { return this + this}
  func add(that: Int) -> Int { return that + that}
}
let addThisApplied: KeyPath<Calculator, Int> = \Calculator.add(3)
let addThatApplied: KeyPath<Calculator, Int> = \Calculator.add(3)

This kind of usage would require a unique external parameter name/label.
I will add these notes to next iteration of the proposal - thank you!

struct Calculator {
  func square(of number: Int) -> Int {
    return number * number
  }
  let square = 16
}
let kpProperty = \Calculator.square
let kpMethod = \Calculator.square

The typechecker will assume kpMethod is also referring to the stored property (it prefers the property member), so the above will either require argument application or specification of the keypath expression type:

let kpProperty = \Calculator.square
let kpMethodWithType: KeyPath<Calculator, (Int) ->Int> = \Calculator.square

Yes, exactly! These annotations are going to require additional work to support in the runtime so they have been excluded here to handle in a later proposal.

1 Like

Mutating methods are not representable as WritableKeyPaths, for the same reason that properties with mutating get are not, since the act of "reading" the method by applying it is what causes the mutation, not writing it.

We should use the same rules as for normal function references and applications, where the labels are always required in a function application, and unapplied references require the labels when the base name alone is ambiguous.

6 Likes

Ah! Thank you for explaining this. I found an emitMethodDescriptor in IRGen and then assumed that this meant this feature would involve emission of descriptors as well.

My current implementation does exactly what you describe - handle the member + apply for applied key paths and member (with no apply) for unapplied key paths. Is the loss of efficiency because of the new component needed for a separate handling of apply? Is there a good way to test for this?

My older pitch from 2020 (thanks for linking!), covered unapplied methods, but went a bit more in depth covering motivating use cases, design details, and corner cases. You might want to study (and steal freely from) that pitch and the attached discussion:

In particular, it addressed some questions about labels, generics, and mutating methods that have come up in this discussion.

You’re welcome to steal text from that older pitch as far as I'm concerned.

1 Like

If unapplied method references can be represented, then it seems like you should be able to form an equivalent key path by appending an application component to the unapplied method:

let applied = \Foo.bar(bas: 42)
let unapplied = \Foo.bar(bas:)
let applied2 = unapplied.appending(path: \.(42))

assert(applied == applied2)

If unapplied key path components exist, then for appending to work naturally, it seems like applied key paths would also have to always be represented as an unapplied method reference component + a function application component.

2 Likes

Nice work!

I've always been wanting something similar to "->" operator overloading in C++. Using this feature with static @dynamicMemberLookup, we can finally implement it in Swift!

:clap: wonderful pitch, and a much needed extension of the keypath system

My only concern is with the fact that the pitch doesn't use the full method names by default, and it's not clear if thay can be used.

For example, in the first code example I would expect keypaths expressed as:

let squareKeyPath = \Calculator.square(of:)
let cubeKeyPath = \Calculator.cube(of:)

As usual, of course, the following forms could be used

let squareKeyPath = \Calculator.square
let cubeKeyPath = \Calculator.cube

if there's no ambiguity, but it should be still possible to refer to the methods in the same way methods references do, that is, by including arguments.

10 Likes

I also welcome this pitch and would be very much in favor of allowing the full method names to eliminate any ambiguity.

+1 This is great. It's a feature I've felt has been missing and I've since relied on using subscript instead.

Few comments/questions

  1. I would like to see a follow-up note about extending support for dynamicMemberLookup to allow for chaining of method calls like we already do support for properties.

  2. Since KeyPath now accepts closure in the Value generic, are we still expecting this to behave properly as a Hashable type?

  3. Are we going to allow the composition of key paths and method calls?

struct Calculator {
  func add(this: Int) -> Int { return this + this}
}
extension Int {
  var string: String { String(describing: self) }
}

let c = Calculator()

do {
  let intToString: KeyPath<Calculator, Int> = \Calculator.add(3).string
  c[keyPath: intToString] // "3"
}

do {
  let intToString: KeyPath<Calculator, Int> = \Calculator.add.string
  c[keyPath: intToString(3)] // "3" ???
}
  1. What about async / throwing functions?
1 Like

I’m very much in favour of this pitch but would prefer if we could use the method’s full name (with argument labels) to disambiguate.

3 Likes

Big picture question: Is there actually a plausible path to such support, or is it currently not understood if it's supportable at all?

As an aside, I'm surprised to see "escaping" among these, but I'm certainly not up on the implementation details.

For the immediate proposal at hand, since async is not supported but we can overload (as I recall), it's important that we don't paint ourselves in a corner. By this I mean: when forming a key path with this proposed feature, the non-async overload would be theoretically unambiguous, even in an async context, since async method key paths aren't supported. But if the feature behaves that way now, then we either have to break source in a new language mode or live with opposite overload preferences for direct invocations and key paths when/if async method key paths become supported in the future—neither of which would be appealing. (Similar considerations might apply for other unsupported features if/when they are supported in future.)