[Pitch #3] Introduce user-defined dynamically "callable" types

I think Python and Ruby require any positional arguments to appear before any keyword arguments. You could enforce this by supporting a third possible variant: dynamicallyCall(withArguments:withKeywordArguments:)

Unlike in Python, method references in JavaScript are unbound by default. Either @dynamicMemberCallable would be needed, or it might be possible to use bind() in the @dynamicMemberLookup implementation.

ThrowingPythonObject intentionally isn't @dynamicCallable, but what are the alternatives?

  • You could make the try or try! required, by merging ThrowingPythonObject into PythonObject.

  • You could keep the try or try! optional, by adding rethrows and an error handler parameter (using a non-throwing fatalError closure as its default argument).

  • Edit: You could use a subscript instead of a property, so that
    try pickle.loads.throwing(blob) becomes
    try pickle.loads[.throwing](blob).

extension PythonObject {

  public enum Throwing {
    case throwing
  }

  public subscript(_: Throwing) -> ThrowingPythonObject {
    return ThrowingPythonObject(self)
  }
}

(PS: the above "implementation PR" link is incorrect).

1 Like

I don't see why we would need to enforce that unlabelled args come before labeled ones... do you?

3 Likes

Unlike in Python, method references in JavaScript are unbound by default. Either @dynamicMemberCallable would be needed, or it might be possible to use bind() in the @dynamicMemberLookup implementation.

Yargh, JavaScript. Using @dynamicMemberCallable to bind methods seems like a fine solution:

extension JSObject {
  func dynamicallyCallMethod(
    named name: String, withArguments args: [JSObject]
  ) -> JSObject {
    // Pseudocode: look up the method, bind it to `self`, then
    // call the bound method.
    return self[dynamicMember: name].bind(self).call(with: args)
  }
}

Regarding throwing dynamic calls: some ideas were posted on the Swift for TensorFlow mailing list: Redirecting to Google Groups

@pvieito proposed the following:

That’s great! For the throwing case I would propose using:
let a = np.throwing.arange(15)

Which is clearer than:
let a = np.arange.throwing(15)

And better than:
let a = np.arange.throwing.dynamicallyCall(withArguments: 15)

I believe this is technically possible.
x.throwing would not be callable, but any dynamic members of x.throwing would be.

Sample implementation:

extension PythonObject {
  var throwing: ThrowingPythonObject { ... }
}

/// This struct only has dynamic member lookup but is not callable.
/// This makes it so that `x.throwing` cannot be directly called.
struct ThrowingPythonObject {
  public subscript(dynamicMember name: String)
    -> ThrowingCallablePythonObject? { ... }
}

/// This struct has dynamic member lookup but is also callable.
struct ThrowingCallablePythonObject {
  public subscript(dynamicMember name: String)
    -> ThrowingCallablePythonObject? { ... }

  // This method is throwing.
  // Dynamic calls return a regular `PythonObject`.
  public func dynamicallyCall(
    withKeywordArguments args:
      DictionaryLiteral<String, PythonConvertible> = [:]
  ) throws -> PythonObject { ... }
}

This enables syntax like the below:

import Python
let np = Python.import("numpy")
let a = try? np.throwing.arange(15)
//           ^~ PythonObject
//              ^~~~~~~~ ThrowingPythonObject
//                       ^~~~~~ ThrowingCallablePythonObject
//                             ^~~~ PythonObject (result of dynamic call)

One question is how to enable throwing behavior for top-level Python builtins, like Python.type since Python.type.throwing would no longer be callable.

An straightforward solution is to support Python.throwing.type(x). This would be done by defining a throwing property on PythonInterface (like PythonObject):

public struct PythonInterface {
  var throwing: ThrowingPythonInterface { ... }
  public subscript(dynamicMember name: String) -> PythonObject { ... }
}

public struct ThrowingPythonInterface {
  public subscript(dynamicMember name: String) -> ThrowingCallablePythonObject {
    return ThrowingCallablePythonObject(self[dynamicMember: name])
  }
}
import Python
let list: PythonObject = [1, 2, 3]
let type: PythonObject? = try? Python.throwing.type(list)
//                             ^~~~~~ PythonInterface
//                                    ^~~~~~~~ ThrowingPythonInterface
//                                             ^~~~ ThrowingCallablePythonObject
//                                                 ^~~~~~ PythonObject (result of dynamic call)

I'm not sure which is more better: pickle.loads[.throwing](blob) or pickle.throwing.loads(blob).

The former is more "magical" but also more straightforward and doesn't collide with dynamic member lookup behavior (accessing a member called x.throwing).

The latter could be more intuitive, but the throwing property may confuse people who aren't familiar with the ThrowingPythonObject behavior and assume it's a dynamic member lookup.

1 Like

On the mailing list thread, Frederick Mayle posted another idea:

Another idea is to make throwing a function which transforms python functions:
let a = try? throwing(np.arange)(15)

My comment:

This is an interesting idea. The throwing(np.arange)(15) syntax is relatively straightforward.
The two adjacent applications might look unfamiliar to Python developers, but throwing and checking aren't needed for simple Python usage.

My main concern is a top-level throwing function might not be ideal, since the name of the function per se doesn't suggest that it's related to PythonObject.


As multiple people have mentioned, there's always the option of defining the "throwing call" method directly on PythonObject:

let arr = try? np.arange.callThrowing(withArguments: 6)

What are everyone's thoughts on the best design for throwing calls?

This looks great. I'm not concerned about its being a top-level function; I expect it would ship with the Python interop package, not the Swift standard library. It makes sense that such a primitive operation is a top-level function, especially when so ergonomic.

2 Likes

On the mailing list thread, Frederick Mayle posted another idea:

Another idea is to make throwing a function which transforms python functions:
let a = try? throwing(np.arange)(15)

This looks great. I'm not concerned about its being a top-level function; I expect it would ship with the Python interop package, not the Swift standard library. It makes sense that such a primitive operation is a top-level function, especially when so ergonomic.

Yes, the top-level function would ship with the Python interop module and be gated by import Python.


I quite like the idea of top-level throwing and checking functions, what do others think?

import Python

// `throwing` example.
let np = Python.import("numpy")
let x1 = np.arange(6)
let x2 = try? throwing(np.arange)(6)

// `checking` example.
let list: PythonObject = [1, 2, 3]
let y1: PythonObject = list[0]
let y2: PythonObject? = checking(list)[0]
1 Like

Throwing being a top-level function feels like the least confusing version to me. So +1 to that.

1 Like

From a purely visual standpoint I find the closing and opening parenthesis in throwing(func)(args) (i.e. the )() throw off my mental parsing that this is a method call a bit, so I'd have a slight preference for let a = np.throwing.arange(15) (akin to the lazy property of AnyBidirectionalCollection).

1 Like

In any case, the python interop API design (esp. how throwing works) seems like an orthogonal topic.

2 Likes