SE-0216: User-defined dynamically callable types

I think "statically callable" and "dynamically callable" are misleading names because (to me) they suggest that the call is statically/dynamically dispatched, which isn't the case.

I'm not sure about the best names for the two concepts but I'll refer to them as "regular callable" and "dynamically callable" in this post.

"Dynamically callable" is the idea described in the proposal: @dynamicCallable types are required to define at least one of two dynamicallyCall methods.

func dynamicallyCall(withArguments: A) -> X
// - `A` can be any type where `A : ExpressibleByArrayLiteral`.
// - `X` can be any type.
func dynamicallyCall(withKeywordArguments: D) -> X
// - `D` can be any type where `D : ExpressibleByDictionaryLiteral` and
//   `D.Key : ExpressibleByStringLiteral`.
// - `X` can be any type.

Calls to an instance of a @dynamicCallable type are desugared to an application of one of the two methods.

@dynamicCallable is limited for the following reasons:

  1. By definition, the dynamicallyCall methods take a variable number of arguments, so it's not possible to enforce a dynamic call to take a fixed number of arguments within the type system. To implement something like std::plus and std::minus, I can only do the following:
@dynamicCallable
enum BinaryOperation<T : Numeric> {
  case add, subtract, multiply

  func dynamicallyCall(withArguments arguments: [T]) -> T {
    // I can't enforce the two argument precondition using the type system.
    // What I really want is: `call(withArguments: (T, T))`.
    precondition(arguments.count == 2, "Must have 2 arguments")
    let x = arguments[0]
    let y = arguments[1]
    switch self {
    case .add:
      return x + y
    case .subtract:
      return x - y
    case .multiply:
      return x * y
    }
  }
}
let add: BinaryOperation = .add
add(1, 2) // works
add(1, 2, 3) // fails at run time, not compile time
  1. The dynamicallyCall methods require all arguments to have the same type. This limits @dynamicCallable to a few specific use cases like dynamic language interoperability.

The "regular callable" concept (I'll refer to it as @callable) address both of these limitations.
@callable types can mark any method as callable: there's no constraint on the type of the method whatsoever.

Calls to an instance of a @callable type are simply forwarded to the callable method.
For the BinaryOperation example above, @callable would be more ideal to enforce a two argument precondition:

@callable
enum BinaryOperation<T : Numeric> {
  case add, subtract, multiply

  @callableMethod // Mark this method as sugar-able.
  func call(withArguments arguments: (T, T)) -> T { ... }
}
let add: BinaryOperation = .add
add(1, 2) // works
add(1, 2, 3) // fails to type-check at compile time

@callable is analogous to operator() in C++ and is more general than @dynamicCallable.
However, it doesn't provide a great answer for dynamic language interoperability because argument labels aren't desugared in the same way.

Here's a Python interop demonstration:

// Testing the Python function `str.format(*args, **kwargs)`.
let greeting: PythonObject = "Hello {name}!"

// With @dynamicCallable: desugared to
// `dynamicallyCall(withKeywordArguments: ["name": "John"])`.
//
// Looks natural and similar to Python: `greeting.format(name="John")`.
greeting.format(name: "John")

// With @callable: there is no argument label sugar.
// Need to construct dictionary manually.
greeting.format(["name": "John"])

To me, it seems the main question is: is dynamic language interoperability important enough to Swift to justify @dynamicCallable?

Note that @dynamicMemberLookup doesn't have the same problem of generality as @dynamicCallable, it is just as useful for language interop as other use cases.

2 Likes