JavaScript function composition: What’s the big deal?

0
34
JavaScript function composition: What’s the big deal?


To hear some people talk, you’d think function composition was some kind of sacred truth. A holy principle to meditate upon whilst genuflecting and lighting incense. But function composition is not complicated. You probably use it all the time, whether you realise it or not. Why, then, do functional programmers get all worked up about it? What’s the big deal?

What is function composition?

Function composition is where we take two functions, and combine them into one. That is, our new function calls one function, takes the result, and passes it into another function. That’s it. In code, it looks like so:

// We call our function c2, short for 'compose two functions together'.
const c2 = (funcA, funcB) => x => funcA(funcB(x));

The one tricky thing going on (if anything) is that we’re returning a function from a function. That’s why there are two arrows in there.

How would we use this in a real world problem? Well, let’s imagine we’re working on some kind of comment system. We want to allow, say, images and links in comments, but not any old HTML. And to make this happen, we’ll create a cut-back version of Markdown. In our cut-back version, a link looks like so:

[link text goes here](http://example.com/example-url)

And an image looks like this:

![alt text goes here](/link/to/image/location.png)

Now, with regular expressions, we can write a function for each. We take a string and replace the pattern with appropriate HTML:

const imagify = str => str.replace(
    /!\[([^\]"<]*)\]\(([^)<"]*)\)/g,
    '<img src="https://jrsinclair.com/articles/2022/javascript-function-composition-whats-the-big-deal/" alt="$1" />'
);
const linkify = str => str.replace(
    /\[([^\]"<]*)\]\(([^)<"]*)\)/g,
    '<a href="https://jrsinclair.com/articles/2022/javascript-function-composition-whats-the-big-deal/" rel="noopener nowfollow">$1</a>'
);

To create a function that converts both images and links, we can use c2():

const linkifyAndImagify = c2(linkify, imagify);

Though, using c2() here isn’t all that much shorter than writing the composition by hand:

const linkifyAndImagify = str => linkify(imagify(str));

Our c2() function saves eight characters. And it gets even worse if we add more functions. For example, suppose we wanted to add support for emphasising with underscores:

const emphasize = str => str.replace(
    /_([^_]*)_/g,
    '<em>$1</em>'
);

We can then add it in with our other functions:

const processComment = c2(linkify, c2(imagify, emphasize));

Compare that with writing the composition by hand:

const processComment = str => linkify(imagify(emphasize(str)));

Using c2(), is still shorter. But not by much. What would be nicer is, if we could define our own custom operator. For example, we could define a bullet operator (•) that composes a function on its right with a function on its left. Then we would build our processComment() function like so:

const processComment = linkify • imagify • emphasize;

Alas, JavaScript doesn’t let us define custom operators yet. Instead, we’ll write a multivariate composition function.

Compose

We want to make composing lots of functions easier. To do that, we’ll use rest parameters to convert a list of arguments into an array. And once we have an array, we can use .reduceRight() to call each function in turn. Putting that into code looks like this:

const compose = (...fns) => x0 => fns.reduceRight(
    (x, f) => f(x),
    x0
);

To illustrate how compose() works, let’s add one more feature to our comment processing. Let’s allow commenters to add <h3> elements by putting three hashes (###) at the beginning of a line:

const headalize = str => str.replace(
    /^###\s+([^\n<"]*)/mg,
    '<h3>$1</h3>'
);

And we can build our function for processing comments like so:

const processComment = compose(linkify, imagify, emphasize, headalize);

If we’re getting short on space, we can put each function on its own line:

const processComment = compose(
    linkify,
    imagify,
    emphasize,
    headalize
);

There’s a small issue here, though. It’s a little awkward that headalize() is the last function listed, but the first function to run. If we’re reading from top to bottom, the functions are in reverse order. This is because compose() mimics the layout we’d have if we did the composition by hand:

const processComment = str => linkify(imagify(emphasize(headalize(str))));

This is why compose() uses .reduceRight() instead of .reduce(). And the order is important. If we ran linikfy() before imagify(), our code doesn’t work. All our images get turned into links.

Compose processes its functions from right to left. We start with a string, pass it to `headalize()`, then `emphasize()`, then `imagify()`, and lastly to `linkify()`, which returns another string.
Compose processes its functions from right to left. We start with a string, pass it to headalize(), then emphasize(), then imagify(), and lastly to linkify(), which returns another string.

If we’re going to write functions in a vertical list, why not reverse the order? We can write a function that composes functions in the other direction. That way, data flows from top to bottom.

Flow

To create a reversed version of compose(), all we need to do is use .reduce() instead of .reduceRight(). That looks like so:

// We call this function 'flow' as the values flow,
// from left to right.
const flow = (...fns) => x0 => fns.reduce(
    (x, f) => f(x),
    x0
);

To show how it works, we’ll add another feature to our comment processing. This time, we’ll add code formatting between backticks:

const codify = str => str.replace(/`([^`<"]*)`/g, '<code>$1</code>');

Throwing that into flow(), we get:

const processComment = flow(
    headalize,
    emphasize,
    imagify,
    linkify,
    codify
);
Flow processes its functions from left to right. We start with a string, pass it to `headalize()`, then `emphasize()`, then `imagify()`, then `linkify()`, and lastly to `codify()`, which returns another string.
Flow processes its functions from left to right. We start with a string, pass it to headalize(), then emphasize(), then imagify(), then linkify(), and lastly to codify(), which returns another string.

This is starting to look much better than if we’d manually composed:

const processComment = str => codify(
    linkify(
        imagify(
            emphasize(
                headalize(str)
            )
        )
    )
);

Indeed, flow() is rather neat. And since it’s rather pleasant to use, we may find ourselves using it to build functions often. But if we only use a function once, sometimes we might get lazy and invoke it immediately. For example:

const processedComment = flow(
    headalize,
    emphasize,
    imagify,
    linkify,
    codify
)(commentStr);

This kind of construction can be awkward at times. Some JavaScript developers find immediately invoked functions disconcerting. Plus, even if our colleagues are fine with it, those double brackets are still a bit ugly.

Never fear, we can create yet another composition function to help us out.

Pipe

We’ll create a new function, pipe(), that uses rest parameters a little differently from flow():

const pipe = (x0, ...fns) => fns.reduce(
    (x, f) => f(x),
    x0
);

Our pipe() function differs from flow() in two significant ways:

  1. It returns a value, not a function. That is, flow() always returns a function, whereas pipe() can return any kind of value.
  2. It takes a value as its first argument. With flow(), all the arguments have to be functions. But with pipe(), the first argument is the value we want to pipe through the functions.

The result is that our composed calculation runs straight away. This means we can’t re-use the composed function. But often, we don’t need to.

To illustrate how pipe() might be useful, let’s change our example a bit. Suppose we have an array of comments to process. We might define a handful of utility functions to work with arrays:

const map    = f => arr => arr =>arr.map(f);
const filter = p => arr => arr.filter(p);
const take   = n => arr => arr.slice(0, n);
const join   = s => arr => arr.join(s);

And perhaps some utility functions for strings too:

const itemize        = str => `<li>${str}</li>`;
const orderedListify = str => `<ol>${str}</ol>`;
const chaoticListify = str => `<ul>${str}</ul>`;
const mentionsNazi   = str => (/\bnazi\b/i).test(str);

We could then put those together with pipe() like this:

const comments = pipe(commentStrs,
    filter(noNazi),
    take(10),
    map(emphasize),
    map(itemize),
    join('\n'),
);

If we squint a little, our pipeline isn’t so different from chaining array methods:

const comments = commentStrs
    .filter(noNazi)
    .slice(0, 10)
    .map(emphasize)
    .map(itemize)
    .join('\n');

Now, someone may feel that the array method chaining looks a little cleaner. They may be right. And someone else may even be wondering why we’d waste time with pipe() and those utility functions. All the utility functions do is call array methods. Why not call them directly? But pipe() has an advantage over method chaining. It can keep piping with bare functions, even when the value in the pipe doesn’t have methods to call. For example, we can add chaoticListify() to our pipeline:

const comments = pipe(commentStrs,
    filter(noNazi),
    take(10),
    map(emphasize),
    map(itemize),
    join('\n'),
    chaoticListify,
);

If we wanted, we could keep adding more functions. And it’s possible to build entire applications this way.

What’s the big deal?

I’ll admit, I think compose(), flow(), and pipe() are pretty neat. But I can also understand if someone is still sceptical. After all, we can still write the pipeline code above using variable assignments:

const withoutNazis       = commentStrs.filter(noNazi);
const topTen             = withoutNazis.slice(0, 10);
const itemizedComments   = topTen.map(itemize);
const emphasizedComments = itemizedComments.map(emphasize);
const joinedList         = emphasizedComments.join('\n');
const comments           = chaoticListify(joinedList);

This code is fine. For lots of people, it’s going to be familiar and readable. It accomplishes the same result as the composed version. Why would anyone bother with pipe()?

To answer that, I’d like us to look at those two code blocks and do two things:

  1. Count the number of semicolons in each.
  2. Observe which utility functions we used in the variable assignment version.

See how the variable assignment version has six semicolons? And how the pipe() version has one? There’s something subtle, but important, going on here. In the variable assignment version, we created six statements. In the pipe() version, we composed the entire thing as an expression. And coding with expressions is the heart of functional programming.

Now, you may not care one whit about functional programming. That’s fine. But using pipe() opens up a whole new way to structure programs. With statements, we write code as a series of instructions to the computer. It’s a lot like a recipe in a cookbook. Do this; then do that; then do this other thing. But with composition, we express code as relationships between functions.

This still doesn’t seem all that impressive. Who cares if composition opens up an alternative way to write code? We’ve been writing statements for decades now, and it gets the job done. Sure, that variable assignment version creates more interstitial variables. But all that’s doing is shifting what part of the call stack the interpreter uses. In essence, both versions are doing the same thing. But the significance of composition isn’t in how it changes the code. No, its significance is in how it changes us. Specifically, how it changes the way we think.

Composition encourages us to think about code as relationships between expressions. This, in turn, encourages us to focus on our desired outcome. That is, as opposed to the details of each step. What’s more, composition also encourages us to code using small, reusable functions. And this reinforces our focus on the outcome over implementation details. As a result, our code becomes more declarative.

Based on our sample code so far, this focus shift may not be obvious. The two examples we’ve been comparing aren’t so different. But we can prove that the pipe() version is more declarative. We can make the pipe() version more efficient without changing a single character. Instead, we’ll change the helper functions it uses:

const map = f => function*(iterable) {
  for (let x of iterable) yield f(x);
};

const filter = p => function*(iterable) {
  for (let x of iterable) {
    if (p(x)) yield x;
  }
};

const take = n => function*(iterable) {
  let i = 0;
  for (let x of iterable) {
    if (i >= n) return;
    yield x;
    i++;
  }
};

const join = s => iterable => [...iterable].join(s);

We don’t change our pipeline at all:

const comments = pipe(commentStrs,
    filter(noNazi),
    take(10),
    map(emphasize),
    map(itemize),
    join('\n'),
    chaoticListify,
);

The details of how the utility functions work aren’t super important. In summary, they use generators instead of the built-in array methods. Using generators means that we no longer create interstitial arrays. But the point here isn’t about efficiency. The generator code may not improve performance at all. It doesn’t matter. The point is that it works. It uses a completely different mechanism for iterating through the data. But it delivers the same result.

The point here is the shift in thinking. To be fair, we could write a version of this code that uses variable assignment and generators. And we’d get the same benefits. But writing the code as a series of statements doesn’t encourage that shift in thinking. We defined our pipeline as relationships between functions. To do that, we needed a bunch of reusable utility functions. In domain-driven design terms, those functions created a natural anti-corruption layer. This let us change the implementation details without altering the high-level intent. And this is why function composition is kind of a big deal.


At its core, function composition isn’t complicated. Combining two functions is straightforward; easy to understand. And we’ve looked at how we can take that idea and extend it to combine lots of functions at once. We’ve explored compose(), flow(), and pipe() as variations on a theme. We can use these functions to create concise, elegant code. But the real beauty of composition isn’t in the code, but in how it changes us. How it gives us new ways of thinking about code.



Source link

Leave a reply

Please enter your comment!
Please enter your name here