How to reconcile Javascript with currying and function composition

后端 未结 2 1181
伪装坚强ぢ
伪装坚强ぢ 2020-12-19 13:23

I love currying but there are a couple of reasons why a lof of Javascript devs reject this technique:

  1. aesthetic concerns about the typical curry pattern:
相关标签:
2条回答
  • 2020-12-19 13:56

    The current prevailing approach provides that each multi argument function is wrapped in a dynamic curry function. While this helps with concern #1, it leaves the remaining ones untouched. Here is an alternative approach.

    Composable functions

    A composable function is curried only in its last argument. To distinguish them from normal multi argument functions, I name them with a trailing underscore (naming is hard).

    const comp_ = (f, g) => x => f(g(x)); // composable function
    
    const foldl_ = (f, acc) => xs => xs.reduce((acc, x, i) => f(acc, x, i), acc);
    
    const curry = f => y => x => f(x, y); // fully curried function
    
    const drop = (xs, n) => xs.slice(n); // normal, multi argument function
    
    const add = (x, y) => x + y;
    
    const sum = foldl_(add, 0);
    
    const dropAndSum = comp_(sum, curry(drop) (1));
    
    console.log(
      dropAndSum([1,2,3,4]) // 9
    );

    With the exception of drop, dropAndSum consists exclusively of multi argument or composable functions and yet we've achieved the same expressiveness as with fully curried functions - at least with this example.

    You can see that each composable function expects either uncurried or other composable functions as arguments. This will increase speed especially for iterative function applications. However, this is also restrictive as soon as the result of a composable function is a function again. Look into the countWhere example below for more information.

    Programmatic solution

    Instead of defining composable functions manually we can easily implement a programmatic solution:

    // generic functions
    
    const composable = f => (...args) => x => f(...args, x);
    
    const foldr = (f, acc, xs) =>
     xs.reduceRight((acc, x, i) => f(x, acc, i), acc);
    
    const comp_ = (f, g) => x => f(g(x));
    
    const I = x => x;
    
    const inc = x => x + 1;
    
    
    // derived functions
    
    const foldr_ = composable(foldr);
    
    const compn_ = foldr_(comp_, I);
    
    const inc3 = compn_([inc, inc, inc]);
    
    
    // and run...
    
    console.log(
      inc3(0) // 3
    );

    Operator functions vs. higher order functions

    Maybe you noticed that curry (form the first example) swaps arguments, while composable does not. curry is meant to be applied to operator functions like drop or sub only, which have a different argument order in curried and uncurried form respectively. An operator function is any function that expects only non-functional arguments. In this sence...

    const I = x => x;
    const eq = (x, y) => x === y; // are operator functions
    
    // whereas
    
    const A = (f, x) => f(x);
    const U = f => f(f); // are not operator but a higher order functions
    

    Higher order functions (HOFs) don't need swapped arguments but you will regularly encounter them with arities higher than two, hence the composbale function is useful.

    HOFs are one of the most awesome tools in functional programming. They abstract from function application. This is the reason why we use them all the time.

    A more serious task

    We can solve more complex tasks as well:

    // generic functions
    
    const composable = f => (...args) => x => f(...args, x);
    
    const filter = (f, xs) => xs.filter(f);
    
    const comp2 = (f, g, x, y) => f(g(x, y));
    
    const len = xs => xs.length;
    
    const odd = x => x % 2 === 1;
    
    
    // compositions
    
    const countWhere_ = f => composable(comp2) (len, filter, f); // (A)
    
    const countWhereOdd = countWhere_(odd);
    
    // and run...
    
    console.log(
       countWhereOdd([1,2,3,4,5]) // 3
    );

    Please note that in line A we were forced to pass f explicitly. This is one of the drawbacks of composable against curried functions: Sometimes we need to pass the data explicitly. However, if you dislike point-free style, this is actually an advantage.

    Conclusion

    Making functions composable mitigates the following concerns:

    1. aesthetic concerns (less frequent use of the curry pattern f(x) (y) (z)
    2. performance penalties (far fewer function calls)

    However, point #4 (readability) is only slightly improved (less point-free style) and point #3 (debugging) not at all.

    While I am convinced that a fully curried approach is superior to the one presented here, I think composable higher order functions are worth thinking about. Just use them as long as you or your coworkers don't feel comfortable with proper currying.

    0 讨论(0)
  • 2020-12-19 14:05

    Note: @ftor answered his/her own question. This is a direct companion to that answer.

    You're already a genius

    I think you might've re-imagined the partial function – at least, in part!

    const partial = (f, ...xs) => (...ys) => f(...xs, ...ys);
    

    and it's counter-part, partialRight

    const partialRight = (f, ...xs) => (...ys) => f(...ys, ...xs);
    

    partial takes a function, some args (xs), and always returns a function that takes some more args (ys), then applies f to (...xs, ...ys)


    Initial remarks

    The context of this question is set in how currying and composition can play nice with a large user base of coders. My remarks will be in the same context

    • just because a function may return a function does not mean that it is curried – tacking on a _ to signify that a function is waiting for more args is confusing. Recall that currying (or partial function application) abstracts arity, so we never know when a function call will result in the value of a computation or another function waiting to be called.

    • curry should not flip arguments; that is going to cause some serious wtf moments for your fellow coder

    • if we're going to create a wrapper for reduce, the reduceRight wrapper should be consistent – eg, your foldl uses f(acc, x, i) but your foldr uses f(x, acc, i) – this will cause a lot of pain amongst coworkers that aren't familiar with these choices

    For the next section, I'm going to replace your composable with partial, remove _-suffixes, and fix the foldr wrapper


    Composable functions

    const partial = (f, ...xs) => (...ys) => f(...xs, ...ys);
    
    const partialRight = (f, ...xs) => (...ys) => f(...ys, ...xs);
    
    const comp = (f, g) => x => f(g(x));
    
    const foldl = (f, acc, xs) => xs.reduce(f, acc);
    
    const drop = (xs, n) => xs.slice(n);
    
    const add = (x, y) => x + y;
    
    const sum = partial(foldl, add, 0);
    
    const dropAndSum = comp(sum, partialRight(drop, 1));
    
    console.log(
      dropAndSum([1,2,3,4]) // 9
    );


    Programmatic solution

    const partial = (f, ...xs) => (...ys) => f(...xs, ...ys);
    
    // restore consistent interface
    const foldr = (f, acc, xs) => xs.reduceRight(f, acc);
    
    const comp = (f,g) => x => f(g(x));
    
    // added this for later
    const flip = f => (x,y) => f(y,x);
    
    const I = x => x;
    
    const inc = x => x + 1;
    
    const compn = partial(foldr, flip(comp), I);
    
    const inc3 = compn([inc, inc, inc]);
    
    console.log(
      inc3(0) // 3
    );


    A more serious task

    const partial = (f, ...xs) => (...ys) => f(...xs, ...ys);
    
    const filter = (f, xs) => xs.filter(f);
    
    const comp2 = (f, g, x, y) => f(g(x, y));
    
    const len = xs => xs.length;
    
    const odd = x => x % 2 === 1;
    
    const countWhere = f => partial(comp2, len, filter, f);
    
    const countWhereOdd = countWhere(odd);
    
    console.log(
       countWhereOdd([1,2,3,4,5]) // 3
    );


    Partial power !

    partial can actually be applied as many times as needed

    const partial = (f, ...xs) => (...ys) => f(...xs, ...ys)
    
    const p = (a,b,c,d,e,f) => a + b + c + d + e + f
    
    let f = partial(p,1,2)
    let g = partial(f,3,4)
    let h = partial(g,5,6)
    
    console.log(p(1,2,3,4,5,6)) // 21
    console.log(f(3,4,5,6))     // 21
    console.log(g(5,6))         // 21
    console.log(h())            // 21

    This makes it an indispensable tool for working with variadic functions, too

    const partial = (f, ...xs) => (...ys) => f(...xs, ...ys)
    
    const add = (x,y) => x + y
    
    const p = (...xs) => xs.reduce(add, 0)
    
    let f = partial(p,1,1,1,1)
    let g = partial(f,2,2,2,2)
    let h = partial(g,3,3,3,3)
    
    console.log(h(4,4,4,4))
    // 1 + 1 + 1 + 1 +
    // 2 + 2 + 2 + 2 +
    // 3 + 3 + 3 + 3 +
    // 4 + 4 + 4 + 4 => 40

    Lastly, a demonstration of partialRight

    const partial = (f, ...xs) => (...ys) => f(...xs, ...ys);
    
    const partialRight = (f, ...xs) => (...ys) => f(...ys, ...xs);
    
    const p = (...xs) => console.log(...xs)
    
    const f = partialRight(p, 7, 8, 9);
    const g = partial(f, 1, 2, 3);
    const h = partial(g, 4, 5, 6);
    
    p(1, 2, 3, 4, 5, 6, 7, 8, 9) // 1 2 3 4 5 6 7 8 9
    f(1, 2, 3, 4, 5, 6)          // 1 2 3 4 5 6 7 8 9
    g(4, 5, 6)                   // 1 2 3 4 5 6 7 8 9
    h()                          // 1 2 3 4 5 6 7 8 9


    Summary

    OK, so partial is pretty much a drop in replacement for composable but also tackles some additional corner cases. Let's see how this bangs up against your initial list

    1. aesthetic concerns: avoids f (x) (y) (z)
    2. performance: unsure, but i suspect performance is about the same
    3. debugging: still an issue because partial creates new functions
    4. readability: i think readability here is pretty good, actually. partial is flexible enough to remove points in many cases

    I agree with you that there's no replacement for fully curried functions. I personally found it easy to adopt the new style once I stopped being judgmental about the "ugliness" of the syntax – it's just different and people don't like different.

    0 讨论(0)
提交回复
热议问题