Amend SE-0216 Dynamic Callable: Reduce Overloads
Introduction
SE-0216 introduced support for user-defined dynamic "callable" types. In this proposal, a type marked @dynamicCallable
must implement one of the following methods:
func dynamicallyCall(withArguments: [T1]) -> T2
func dynamicallyCall(withKeywordArguments: [S : T3]) -> T4
The original proposal stated that when both these methods are provided in the primary declaration of the type, the type checker finds the most specific match.
This proposal demonstrates that allowing both methods to be provided for @dynamicCallable
can lead to unclear, surprising behavior, and proposes that the user must choose one method from the two.
Motivation
The proposed amendment is motivated by three things:
1. Unclear which method is being called, when no keywords are given
In a language that supports both normal arguments and keyword arguments, both of which are supported by dynamicallyCall(withKeywordArguments:)
, in which case keywords will be empty strings when a normal argument list is provided.
@dynamicCallable
struct T {
func dynamicallyCall(withKeywordArguments: KeyValuePairs<String, Int>) -> Float { ... }
}
let x: T = ...
x(a, b)
// desugared: x.foo.dynamicallyCall(withKeywordArguments: ["": a, "": b])
x(a: a, b: b)
// desugared: x.foo.dynamicallyCall(withKeywordArguments: ["a": a, "b": b])
But when dynamicallyCall(withArguments:)
is also implemented, a dynamic call with no argument labels will be desugared into the array initializer instead of the dictionary literal initializer.
@dynamicCallable
struct T {
func dynamicallyCall(withKeywordArguments: KeyValuePairs<String, Int>) -> Float { ... }
}
let x: T = ...
x(a, b)
// desugared: x.foo.dynamicallyCall(withArguments: [a, b])
x(a: a, b: b)
// desugared: x.foo.dynamicallyCall(withKeywordArguments: ["a": a, "b": b])
Now we have two different ways of representing the same case: the array form and the dictionary literal form, and the latter has the full representational power to express calls without keywords. In languages that support keyword arguments, calls with keyword arguments are usually handled the same way as non-keyword arguments, there's hardly any motivation for implementing a separate dynamicallyCall(withArguments:)
when dynamicallyCall(withKeywordArguments:)
is already implemented by a dynamic callable type.
2. Potentially surprising behavior when two methods have different return types
When dynamicallyCall(withArguments:)
and dynamicallyCall(withKeywordArguments:)
are both provided and have different return types, the behavior can be surprising: It is not obvious which method a dynamic call is being desugared to.
@dynamicCallable
struct T {
func dynamicallyCall(withArguments: [Int]) -> String { ... }
func dynamicallyCall(withKeywordArguments: KeyValuePairs<String, Int>) -> Float { ... }
}
let x: T = ...
let _: String = x()
// desugared: x.foo.dynamicallyCall(withArguments: [])
let _: String = x(a: a)
// ^ error: expression returns Float
let _: Float = x()
// desugared: x.foo.dynamicallyCall(withKeywordArguments: [:])
One may argue that this is a customization point that libraries can leverage to get intended behavior. However, this customization point is not motivated by concrete use cases and can lead to unintended behavior, so it should be reassessed and/or disallowed.
3. Implementation is significantly harder than claimed in the original proposal
The implementation of this proposal is still in progress. @dan-zheng's implementation hit a blocker due to difficulty in resolving overloads described above in the constraint system. Dan has detailed explanations here.
Implementation difficulty is a minor argument in this proposal. This proposal is mainly motivated by #1 and #2.
Proposed solution
As explained above, there's no concrete use case or demonstrated merit in allowing the user to provide both methods and resolving dynamic calls based on "most specific match", and at times it makes the call sites unclear and behavior surprising. We should not allow such customization until there's a convincing use case that would form a new proposal to enable this customization point.
Dynamic callable types must choose between dynamicallyCall(withArguments:)
and dynamicallyCall(withKeywordArguments:)
. When both methods are provided in the primary type declaration, the compiler emits an error.
@dynamicCallable
struct T {
func dynamicallyCall(withArguments: [Int]) -> String { ... }
func dynamicallyCall(withKeywordArguments: KeyValuePairs<String, Int>) -> Float { ... }
}
@dynamicCallable
^~~~~~~~~~~~~~~~
error: 'T' has both 'dynamicallyCall(withArguments:)' and 'dynamicallyCall(withKeywordArguments:)' methods, but only one of them should exist
func dynamicallyCall(withArguments: [Int]) -> String { ... }
^~~~~~~~~~~~~~~
note: found this method for '@dynamicCallable'
func dynamicallyCall(withKeywordArguments: KeyValuePairs<String, Int>) -> Float { ... }
^~~~~~~~~~~~~~~
note: found this method for '@dynamicCallable'
Alternatives considered
-
Make
T2
equalT4
so that all dynamic calls return the same type.
This would resolve the second concern, but won't resolve other concerns. -
@George suggested a value type
DynamicArguments
which has bothsubscript(_: String)
andsubscript(_: Int)
. This author believes that this would make interoperability less safe when we interoperate with a language that does not support keyword arguments. In the existing solution, implementingdynamicallyCall(withArguments:)
without implementingdynamicallyCall(withKeywordArguments:)
can guarantee that a call likex(a: b)
gets rejected at compile-time. ButDynamicArguments
would not provide that guarantee. Moreover, the existing approach maintains better simplicity in that it doesn't introduce or require the user to internalize any new stdlib type.