Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Idea: J-style forks #544

Open
techieji opened this issue Aug 17, 2022 · 6 comments
Open

Idea: J-style forks #544

techieji opened this issue Aug 17, 2022 · 6 comments

Comments

@techieji
Copy link

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
Copy link
Member

eriknw commented Aug 18, 2022

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
Copy link
Author

techieji commented Aug 18, 2022

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
Copy link

mentalisttraceur commented Jan 14, 2023

!!!

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
Copy link

mentalisttraceur commented Jan 14, 2023

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
Copy link

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
Copy link

mentalisttraceur commented Jan 14, 2023

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants