How does compose function work with multiple parameters?
Asked Answered
T

1

4

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]?

Tungusic answered 23/9, 2021 at 9:5 Comment(0)
S
2

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.

Shalna answered 23/9, 2021 at 10:18 Comment(2)
Thank you for your answer. One thing I didn't quite get is how "...restArgs expands to just 1 after the comma"? How the second value is being unpacked from an array?Tungusic
I'm not expert, but the point is simply that if a function has a parameter that is written as ...restArgs then the corresponding argument restArgs will be an array of all those args; when you write ...restArgs in the body of the function, you expand that array to a comma separated thing. To see the difference, enter ((...x) => [...x, x])(1,2,3) at the prompt and inspect the result.Shalna

© 2022 - 2024 — McMap. All rights reserved.