pytoolz / toolz

A functional standard library for Python.
http://toolz.readthedocs.org/
Other
4.66k stars 259 forks source link

Idea: J-style forks #544

Open techieji opened 2 years ago

techieji commented 2 years ago

I recently wrote a rough Python implementation of J-style forks which I ended up using a lot, and I wanted to see whether such a function belonged in toolz:

from toolz.functoolz import curry, apply

def fork(combine, *fns):
    @curry
    def f(*args):
        return combine(*map(apply, fns, args))
    return f

# Usage: fork(f, g, h)(x, y) is equivalent to f(g(x), h(y))
from operator import add
dup = lambda x: x * 2
trip = lambda y: y * 3
fn_2x_plus_3y = fork(add, dup, trip)
assert fn_2x_plus_3y(1, 2) == 2*1 + 3*2

The most similar function currently in toolz is juxt, but while (I think) juxt aims to contrast different functions, fork preprocesses the arguments to the main function. It also has precedent in J (but whether you can use J for precedent is up for debate).

If this belongs in toolz, I'd love to submit a PR for it.

eriknw commented 2 years ago

Neat! Thanks for sharing @techieji. I can see value in this. I'd like to play around with this a little more to try to come up with a good motivating example. Can you share other examples of when you have used this?

techieji commented 2 years ago

I personally used this function in a programming language I'm writing to turn

def var(self, tree):
    return self.env[childn(0, tree)]

into the point-free

var = fork(dict.__getitem__, attrgetter('env'), childn(0))   # childn is curried
mentalisttraceur commented 1 year ago

!!!

I just realized - this is (a special case of) partialcompose! (Link goes to another GitHub discussion where I first tried to describe the concept.)

The more general pattern here is the combination of partial application and function composition concepts; a function composition tree rather than a line; partial function composition where each composition only applies to some arguments!

Or in code example instead of words, the generalization is (actually this is a simplified version):

partialcompose(f, g, h, foo=dict, qux=compose(str, int))(*args, **kwarg) -> f(g(args[0]), h(args[1]), *args[2:], foo=dict(kwargs['foo']), qux=str(int(kwargs['qux'])), **kwargs)

So we can see how fork is a special case (for just positional arguments, where each function applies to exactly one argument) of partialcompose: fork(f, g, h)(a, b) -> partialcompose(f, g, h)(a, b) -> f(g(a), h(b)).

mentalisttraceur commented 1 year ago

So I oversimplified the above description of partialcompose.

The full variation of the concept I have in mind (which might be overly complex to actually implement), is to inspect the signatures of all the composed functions, and use that to determine which arguments the partially composed functions "consume" and/or "share".

The logic for that variant is pretty convoluted to describe, but the general gist is: how might we generalize it if we had functions that wanted to preprocess multiple arguments at once, or functions that wanted to preprocess specific named arguments, and how might we handle variadic arguments, and so on and so on?

But even for that version of the concept, fork is still just a special case of partialcompose, just one where g and h are both hard-coded unary functions whose arguments are not keyword-only.

mentalisttraceur commented 1 year ago

Note also that partial and compose are always special cases/reductions of partialcompose! (Of the more complex variant of partialcompose which takes arity/signatures of the partially-composed functions into account.)

If we have

def always_returns(value):
    def returner():
        return value
    return returner

then

partialcompose(f, always_returns(1)) -> partial(f, 1).

and

partialcompose(f, g)(...) -> compose(f, g)(...) (and compose(f, g, h) is partialcompose(f, partialcompose(g, h)) or partialcompose(partialcompose(f, g), h)).

So there is some larger abstract conceptual shape here, and partial, compose, and this "J-style" fork are three different simple sides/protrusions of it. (Kinda like how "monads" are this single larger abstraction, and certain common patterns of how we handle None, lists, and errors turn out to be three special cases of it.)

mentalisttraceur commented 1 year ago

So I would discourage naming it "fork" because that name is very general, vague, to me counter-intuitive (it only makes sense to me after connecting it to my partialcompose concept, which can be visualized as a tree of composed functions), and would collide on an import * with os.fork. I think there's at least one or two associations with "fork" that many developers are likely to have before they think of what this function does.

And I would somewhat discourage having a function that only partially composes positional arguments, because that's kinda like having a partial which can only bind positional arguments.

But I do think there is a very, very good idea in this direction.