Search code examples
javascriptfunctional-programmingcompositioncurryingpartial-application

How does compose function work with multiple parameters?


Here's a 'compose' function which I need to improve:

const compose = (fns) => (...args) => fns.reduceRight((args, fn) => [fn(...args)], args)[0];

Here's a practical implementation of one:

const compose = (fns) => (...args) => fns.reduceRight((args, fn) => [fn(...args)], args)[0];

const fn = compose([
  (x) => x - 8,
  (x) => x ** 2,
  (x, y) => (y > 0 ? x + 3 : x - 3),
]);

console.log(fn("3", 1)); // 1081
console.log(fn("3", -1)); // -8

And here's an improvement my mentor came to.

const compose = (fns) => (arg, ...restArgs) => fns.reduceRight((acc, func) => func(acc, ...restArgs), arg);

If we pass arguments list like that func(x, [y]) with first iteration, I still don't understand how do we make function work with unpacked array of [y]?


Solution

  • Let's analyse what the improved compose does

    compose = (fns) =>
              (arg, ...restArgs) =>
              fns.reduceRight((acc, func) => func(acc, ...restArgs), arg);
    

    When you feed compose with a number of functions, you get back... a function. In your case you give it a name, fn.

    What does this fn function look like? By simple substitution you can think of it as this:

    (arg, ...restArgs) => fns.reduceRight((acc, func) => func(acc, ...restArgs), arg);
    

    where fns === [(x) => x - 8, (x) => x ** 2, (x, y) => (y > 0 ? x + 3 : x - 3)].

    So you can feed this function fn with some arguments, that will be "pattern-matched" against (arg, ...restArgs); in your example, when you call fn("3", 1), arg is "3" and restArgs is [1] (so ...restArgs expands to just 1 after the comma, so you see that fn("3", 1) reduces to

    fns.reduceRight((acc, func) => func(acc, 1), "3");
    

    From this you see that

    1. the rightmost function, (x, y) => (y > 0 ? x + 3 : x - 3) is called with the two arguments "3" (the initial value of acc) and 1,
    2. the result will be passed as the first argument to the middle function with the following call to func,
    3. and so on,

    but the point is that the second argument to func, namely 1, is only used by the rightmost function, whereas it is passed to but ignored by the other two functions!

    Conclusion

    Function composition is a thing between unary functions.¹ Using it with functions with higher-than-1 arity leads to confusion.²

    For instance consider these two functions

    square = (x) => x**2;   // unary
    plus = (x,y) => x + y;  // binary
    

    can you compose them? Well, you can compose them into a function like this

    sum_and_square = (x,y) => square(plus(x,y));
    

    the compose function that you've got at the bottom of your question would go well:

    sum_and_square = compose([square, plus]);
    

    But what if your two functions were these?

    apply_twice = (f) => ((x) => f(f(x))); // still unary, technically
    plus = (x,y) => x + y;                 // still binary
    

    Your compose would not work.

    Even though, if the function plus was curried, e.g. if it was defined as

    plus = (x) => (y) => x + y
    

    then one could think of composing them in a function that acts like this:

    f = (x,y) => apply_twice(plus(x))(y)
    

    which would predictably produce f(3,4) === 10.

    You can get it as f = compose([apply_twice, plus]).

    A cosmetic improvement

    Additionally, I would suggest a "cosmetic" change: make compose accept ...fns instead of fns,

    compose = (...fns)/* I've only added the three dots on this line */ =>
              (arg, ...restArgs) =>
              fns.reduceRight((acc, func) => func(acc, ...restArgs), arg);
    

    and you'll be able to call it without groupint the functions to be composed in an array, e.g. you'd write compose(apply_twice, plus) instead of compose([apply_twice, plus]).

    Btw, there's lodash

    There's two functions in that library that can handle function composition:


    (¹) This is Haskell's choice (. is the composition operator in Haskell). If you apply f . g . h to more than one argument, the first argument will be passed thought the whole pipeline; that intermediate result will be applied to the second argument; that further intermediate result will be applied to the third argument, and so on. In other words, if you had haskellCompose in JavaScript, and if f was binary and g and h unary, haskellCompose(f, g, h)(x, y) would be equal to f(g(h(x)), y).

    (²) Clojure's comp instead takes another choice. It saturates the rightmost function and then passes the result over to the others. So if you had clojureCompose in JavaScript, and f and g where unary while h binary, then clojureCompose(f, g, h)(x, y) would be equal to f(g(h(x,y))).

    Might be because I'm used to Haskell's automatically curryed functions, but I prefer Haskell's choice.