Array.map
We all probably know Array.map
. It transforms an array of elements according to a given function.
double = (x) => x * 2;
map(double, [1, 2, 3]);
// [2, 4, 6]
I’ve always seen it implemented along these lines:
map = (fn, arr) => {
const mappedArr = [];
for (let i = 0; i < arr.length; i++) {
let mapped = fn(arr[i]);
mappedArr.push(mapped);
}
return mappedArr;
};
This video exposed me to an alternative Array.map
implementation. It’s from a 2014 JSConf — way before I jumped on the functional programming bandwagon.
Edit: David Cizek and Stephen Blackstone kindly pointed out edge-cases and suboptimal performance regarding this map
implementation. I wouldn’t advise anyone use this in a real app. My intention’s that we appreciate and learn from this thought-provoking, recursive approach. 😁
The original example’s in CoffeeScript, here’s a JavaScript equivalent.
map = (fn, [head, ...tail]) =>
head === undefined ? [] : [fn(head), ...map(fn, tail)];
You might use David Cizek’s safer implementation instead.
map = (_fn_, [_head_, ..._tail_]) _=>_ (
head === undefined && tail.length < 1
? []
: [fn(head), ...map(fn, tail)]
);
Using ES6’s destructuring assignment, we store the array’s first element into the variable head
. Then we store all the other array elements into tail
.
If head
is undefined
, that means we have an empty array, so just return an empty array. We’ve mapped nothing.
map(double, []);
// []
If head
is not undefined
we return a new array with fn(head)
as the first element. We’ve now mapped the array’s first element. Alongside it is map(fn, tail)
which calls map
again, this time with one less element.
Since map
returns an array, we use ES6’s spread syntax to concatenate it with [head]
.
Let’s step through this in the debugger. Paste this into your browser’s JavaScript console.
map = (fn, [head, ...tail]) => {
if (head === undefined) {
return [];
}
debugger;
return [fn(head), ...map(fn, tail)];
};
Now let’s map(double, [1, 2, 3])
.
We see our local variables:
head: 1
tail: [2, 3]
fn: double
We know fn(head)
is 2
. That becomes the new array’s first element. Then we call map
again with fn
and the rest of the array’s elements: tail
.
So before the initial map
call even returns, we’ll keep calling map
until the array’s been emptied out. Once the array’s empty, head
will be undefined
, allowing our base case to run and finish the whole process.
On the next run, head
is 2
and tail
is [3]
.
Since tail
isn’t empty yet, hit the next breakpoint to call map
again.
head
is 3
, and tail
is an empty array. The next time this function runs, it’ll bail on line 3 and finally return the mapped array.
And here’s our end result:
Array.filter
Array.filter
returns a new array based on the elements that satisfy a given predicate function.
isEven = (x) => x % 2 === 0;
filter(isEven, [1, 2, 3]);
// [2]
Consider this recursive solution:
filter = (pred, [head, ...tail]) =>
head === undefined
? []
: pred(head)
? [head, ...filter(pred, tail)]
: [...filter(pred, tail)];
If map
made sense, this’ll be easy.
We’re still capturing the array’s first element in a variable called head
, and the rest in a separate array called tail
.
And with the same base case, if head
is undefined
, return an empty array and finish iterating.
But we have another conditional statement: only put head
in the new array if pred(head)
is true
, because filter
works by testing each element against a predicate function. Only when the predicate returns true
, do we add that element to the new array.
If pred(head)
doesn’t return true
, just call filter(pred, tail)
without head
.
Let’s quickly expand and step through this in the Chrome console.
filter = (pred, [head, ...tail]) => {
if (head === undefined) return [];
if (pred(head)) {
debugger;
return [head, ...filter(pred, tail)];
}
debugger;
return [...filter(pred, tail)];
};
And look for numbers ≤ 10:
filter(x => x <= 10, [1, 10, 20]);
Since our array’s [1, 10, 20]
, head
is the first element, 1, and tail
is an array of the rest: [10, 20]
.
The predicate tests if x
≤ 10, so pred(1)
returns true
. That’s why we paused on line 4’s debugger
statement.
Since the current head
passed the test, it’s allowed entry into our filtered array. But we’re not done, so we call filter
again with the same predicate, and now tail
.
Move to the next debugger
.
We called filter
with [10, 20]
so head
is now 10, and tail
is [20]
. So how does tail
get smaller with each successive iteration?
We’re on line 4’s debugger
once again because because 10 ≤ 10. Move to the next breakpoint.
head
’s now 20 and tail
’s empty.
Since 20 > 10, pred(head)
returns false
and our filtered array won’t include it. We’ll call filter
one more time without head
.
This next time, however, filter
will bail on line 2. Destructuring an empty array gives you undefined
variables. Continue past this breakpoint to get your return value.
That looks correct to me!
Array.reduce
Last but not least, Array.reduce
is great for boiling an array down to a single value.
Here’s my naive reduce
implementation:
reduce = (fn, acc, arr) => {
for (let i = 0; i < arr.length; i++) {
acc = fn(acc, arr[i]);
}
return acc;
};
And we can use it like this:
add = (x, y) => x + y;
reduce(add, 0, [1, 2, 3]); // 6
You’d get the same result with this recursive implementation:
reduce = (fn, acc, [head, ...tail]) =>
head === undefined ? acc : reduce(fn, fn(acc, head), tail);
I find this one much easier to read than recursive map
and filter
.
Let’s step through this in the browser console. Here’s an expanded version with debugger
statements:
reduce = (fn, acc, [head, ...tail]) => {
if (head === undefined) {
debugger;
return acc;
}
debugger;
return reduce(fn, fn(acc, head), tail);
};
Then we’ll call this in the console:
add = (x, y) => x + y;
reduce(add, 0, [1, 2, 3]);
Round 1
We see our local variables:
acc
: our initial value of 0
fn
: our add
function
head
: the array’s first element, 1
tail
: the array’s other elements packed into a separate array, [2, 3]
Since head
isn’t undefined
we’re going to recursively call reduce
, passing along its required parameters:
fn
: Obviously the add
function again 😁
acc
: The result of calling fn(acc, head)
. Since acc
is 0
, and head
is 1
, add(0, 1)
returns 1
.
tail
: The array’s leftover elements. By always using tail, we keep cutting the array down until nothing’s left!
Move to the next debugger
.
Round 2
Local variables:
acc
: Now it’s 1
, because we called reduce
with fn(acc, head)
, which was add(0, 1)
at the time.
fn
: Still add
!
head
: Remember how we passed the previous tail
to reduce
? Now that’s been destructured, with head
representing its first element, 2
.
tail
: There’s only one element left, so 3
’s been packed into an array all by itself.
We know the next reduce
call will take a function, accumulator, and array. We can evaluate the next set of parameters using the console.
Expect these values on the next breakpoint.
Round 3
Our local variables are as expected. head
’s first and only element is 3
.
And our array only has one element left, tail
’s empty! That means the next breakpoint will be our last.
Let’s quickly evaluate our future local variables:
Move to the final breakpoint.
Round 4
Check it out, we paused on line 3 instead of line 6 this time! head
is undefined
so we’re returning the final, 6
! It’ll pop out if you move to the next breakpoint.
Looks good to me! Thank you so much for reading this.