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
- Proposal: SE-NNNN
- Authors: Amritpan Kaur, Pavel Yaskevich
- Review Manager: TBD
- Status: Awaiting implementation
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:
- Without argument application: The key path represents the unapplied method signature.
- 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)
}