The pipeline operator is one of features of many functional languages. Take a look at this F# example:
[1; 2; 3; 4;] |> List.filter (fun x -> x % 2 <> 0) |> List.map (fun x -> x * x + 1)
What problem this syntax solves?
Think of a typical app. User inputs some data and the app processes the data. There's validation, then some processing, then mapping input to an output.
A usual flow in an imperative/object language, is a sequence of loops (for/while). Each loop does some filtering, ordering, mapping, groupping etc.
var list = [1,2,3,4];
for ( var i=0; i < list.length; i++ )
{
// validation
if ( ... ) { ... }
}
for ( var i=0; i < list.length; i++ )
{
// processing
}
// ...
It's usually hard to follow such code and also not easy to maintain it. Loops can have side effects, the list is modified or maybe it's not, loop indexes have to be carefully inspected to find possible subtle mistakes. When a loop groups data lists into dictionaries, things get even more complicated.
But, if a list has its own programming interface, the code can be refactored to much cleaner:
var result =
[1,2,3,4]
.filter( x => x < 2 ) // validation
.map( x => x * 2 ) // processing
...
This clean syntax is only possible because of existence of these specific methods, filter or map. These methods take a list and return another list which is an argument to the very next method call. The code flow is very clear, arguments are clear.
But what if you want to call a method that is not built into the array interface?
var result =
[1,2,3,4]
.filter( x => x < 2 ) // validation
.saveToDatabase() // ?? missing, there's no such method
...
In functional approach, such pipeline can be implemented using functions that have exactly one parameter - a list and one return value - a list.
function filterEvens( xs ) {
return xs.filter( x => x % 2 == 0 );
}
function double( xs ) {
return xs.map( x => x*2);
}
console.log(
double(
filterEvens(
[1,2,3,4,5]
)
)
)
This is a step in a right direction. It no longer relies on built-in methods, instead, custom functions can be easily implemented. The saveToDatabase would be just another function that takes a list and returns a list (so that in can be chained further).
The problem here is that the syntax is unfortunate. The actual argument (the data) is in the middle of a possibly long sequence of calls and functions that are applied to the data are written bottom-to-top instead of top-to-bottom. In the above example, what we see is double followed by filterEvens while in fact it's first filterEvens that is applied, followed by double. Such code where the argument is in the very center (or at the top, just tilt your head to the right) is called a Pyramid of Doom and it's a problem not only in JavaScript.
Let's try to fix that by introducing the pipeline operator, just like F#'s |>. It's the operator that makes the code clean and functions applied in the very same order they appear in the code.
In JavaScript, we can't override a custom operator. You just can't have the |>. But, we can have a function that does the same.:
function pipe(xs, ...[f, ...fs]) {
return f ? pipe( f(xs), ...fs ) : xs;
}
This pipe function is really simple. It takes a list (xs) and a list of functions (using the spread operator). It uses recursion to apply a next function to return value of previous function. Calling:
pipe( xs, f1, f2, f3 )
would yield:
f3( f2( f1( xs )))
Make sure it really does so! Using this simple function, we can rewrite the previous pipeline to:
console.log(
pipe(
[1,2,3,4,5],
filterEvens,
double
)
);
Another step in the right direction. No, we still don't have the |> but we have the pipe function that does the same.
But - do we really need an extra function in the pipeline for every possible operation we would like to perform? In the example, to filter even numbers we have a function. Does it mean that any other filtering, like odd numbers or persons that are older than 18 years or orders that are ready to be shipped, would require its own extra function?
No, we can generalize that!
How do we generalize a function - like a filtering function - so that it takes just one parameter (a list) but in the very same time, another parameter, the filtering predicate?
Just make a function with two parameters?
Nope, we need exactly one!
Well, technically yes and no. We can have a higher order function. A function that takes parameters but returns another function with its own parameters. A function that creates functions. A function factory.
function map( f ) {
return function( xs ) {
return xs.map( f );
}
}
function filter( predicate ) {
return function( xs ) {
return xs.filter( predicate );
}
}
function sort( compareFn ) {
return function( xs ) {
return xs.sort( compareFn );
}
}
function reduce( foldf ) {
return function( xs ) {
return xs.reduce( foldf );
}
}
function flat() {
return function( xs ) {
return xs.flat();
}
}
function take(n) {
return function( xs ) {
return xs.slice(0, n);
}
}
console.log(
pipe(
[1,2,3,4,5],
map( n => n + 1 ),
filter( x => x < 4 ),
map( n => n.toString() ),
map( n => [n, n] ),
flat(),
sort( (n,m) => n-m ),
map( n => +n ),
reduce( (prev, curr) => prev + curr )
)
);
With this approach, creating new pipeline operators is easy. And, compare this syntax to the F# example from the very beginning of this post.
To make it even more fun, all these pipeline operators that actually kind of wrap existing array methods can be also generalized using yet another level of abstraction:
function curryArrayMethod(method) {
return function curried(func) {
return function(array) {
return method.call(array, func);
};
};
}
const curriedFilter = curryArrayMethod(Array.prototype.filter);
const curriedMap = curryArrayMethod(Array.prototype.map);
console.log(
pipe(
[1,2,3,4,5],
curriedFilter( x => x < 4 ),
curriedMap( x => x * 2 )
)
);
Have fun with functional pipelines in JavaScript.
As an excercise, you can add your own operator (like the group operator) or modify the sort operator so that instead of a comparer:
... sort( (n,m) => n-m )
it takes a function that gets the sort key of a single value and internally uses this function to compare two values, so that you can call sort like:
... sort( person => person.name )
No comments:
Post a Comment