jackfirth / racket-mock

Mocking library for Racket
Other
22 stars 7 forks source link

Arguments: Syntactic unpacking of arguments #135

Open countvajhula opened 4 years ago

countvajhula commented 4 years ago

Did I mention I'm a huge fan of the arguments package? There's just one thing that I wish it had -- syntactic unpacking of arguments at definition and at invocation time. I've thought about implementing this and submitting a PR when my macro-fu was up to the task, but I'm not there yet and I'm not sure when I'd get to it, so I'm creating this issue here for now as a feature request.

The idea is to be able to identify specific arguments in addition to being able to "pack" the rest, similar to how Python does it.

Spec:

(apply/arguments f p1 p2 ... #:k1 #:k2 ... args)
(define/arguments f p1 p2 ... #:k1 #:k2 ... args)

These would essentially just be more general versions of the built-in apply and define, with the only difference being that they support an arguments tail.

Example:

(define/arguments (my-function arg1 arg2 #:key [key null] . args)
  ;; ... body ...)

Here, the first two arguments to the function should be bound in the body, and if a keyword argument key is supplied, it should be bound in the body as well (in this example it should always be bound because of the default null value). Any other positional or keyword arguments should be packed into the args arguments structure, the same as current behavior.

Likewise,

(define args (make-arguments (list 7 8 9) (hash '#:a 1 '#:b 2)))
(apply/arguments my-function 5 #:kw1 "blue" args)

... should bind arg1 to 5 and arg2 to 7, and pack (list 8 9) and (hash '#:kw1 "blue" '#:a 1 '#:b 2) into the args struct.

For reference, here is a case where this feature would help, by avoiding the need to explicitly unpack the argument in the body of the function when it is already known to be needed at definition time. There are other such examples in that file, too. And in general, this is verbatim the behavior in python, so python examples of argument packing/unpacking would illustrate this feature as well.

jackfirth commented 4 years ago

This seems like it could have some overlap with pattern matching. Would being able to unpack arguments instances with pattern matching be enough?

countvajhula commented 4 years ago

Do you mean something like this?

(define/arguments (my-function args)
  (match args
     [(arg1 arg2 #:key [key null] . remaining-args)
        ;; ... body ...)]))

It could be that if such pattern matching existed, we could use it to trivially expand the define/arguments and apply/arguments forms to support the kind of unpacking I'm talking about. But being able to do it without an explicit pattern matching step would be a useful addition on top of this, at least in part because the entire body of the function would often be enclosed in a single match clause. For instance in this example, that containing let expression could be eliminated if we could just unpack the args in the function definition. Also, does define/arguments support specifying default values (like [key null]) in the definition? A nice thing about the proposed behavior is that it's a neat superset of define and apply, essentially so that define/arguments and apply/arguments could be drop-in replacements for the built-in define and apply, but with seamless packing and unpacking of extra arguments, making it potentially compelling as a standard.

countvajhula commented 3 years ago

As a digression on this subject that may nevertheless serve as another example, I've been mulling over writing a "generic constructor" as a generic interface. The idea is that when creating types we usually need two constructors, a unary/n-ary constructor (like cons) and a nullary one (like null), and sometimes also a variadic one (like list). But really we only need to specify one of these to unambiguously indicate what type we're trying to create.

Continuing with this line of thought it might make sense to define a generic interface like this:

(define-generics form
  (make form element ...))

[edit: looks like this is technically invalid syntax, but that's not really relevant so leaving as is :) ]

Then, (make (list 1 2) 3) would construct a list, '(1 2 3), while (make #(1 2) 3) would construct a vector. Likewise (make empty-stream 3) would construct a stream, and (make empty-arguments 1 2 3) would construct an arguments structure. In all cases we only need to specify the nullary constructor or provide an existing instance of the desired type, and make stands in for e.g. cons and stream-cons or whatever custom constructor someone might define for a type. This also has the advantage over variadic constructors like list and stream that we don't need to know what type is being constructed up front, and it can be determined at runtime (e.g. (make val 3)).

At this point, this is essentially equivalent to data/collection's gen:collection, and using conj, for example.

But since types in general may entail different structures that won't necessarily fit into cons/null style construction, we'd want make to support any function signature -- left to the discretion of the author of the type -- as long as the first argument remains the instance of the type, since we'd need that to be unambiguous in order for the generic interface to dispatch based on it.

In other words, we'd need the generic interface to look more like this:

(define-generics form
  (make form . elements)

... where elements is a packed arguments structure. As far as I can tell this isn't possible with the existing define-generics form, so that could motivate either a possible define-generics/arguments form, or, maybe even make a case for core-level support for arbitrary packed arguments (e.g. python). While generic interfaces are outside the scope of the present issue, this is a situation where in principle we'd need to / like to be able to syntactically unpack at least one argument for dispatch purposes.