After much delay, I am finally getting around to writing a follow-up
to our first in-person meetup! This time we were talking about
recursion and a particular pattern that arises when applying
recursion on JavaScript arrays, which Iāll call āfoldingā for now.
Thereās plenty to talk about with recursion, so for this post Iāll just
concentrate on folding, but keep in mind that there are plenty of other
use cases for recursion. You can look over the Git repo for the meetup
hereāthere
are two branches: master
was our starting point and
solutions we worked out were pushed on to a separate branch,
solved
.
A recursive sum
Letās suppose we wanted to write a recursive sum
function that works on JavaScript arrays. So sum([1, 3, 8])
should equal 1 + 3 + 8
, or 12
. If we want to
think of this as a recursive function we want to re-frame it as a
function that calls itself (weāll take this as a minimal
definition of recursion for now). OK, so instead of saying (in
pseudo-code):
sum([1, 3, 8]) = 1 + 3 + 8
we could say
sum([1, 3, 8]) = 1 + sum([3, 8])
Now itās starting to look recursive! But how do we translate that into a function definition? Letās try:
const sum = (arr) => {
const [num, ...nums] = arr;
return num + sum(nums);
; }
Here weāre using array destructuring to get the head, or
first element (num
), and tail, or everything but
the first element (nums
), of our array. Weāre adding the
num
to the result of calling sum
on the
nums
(kind of like what we were doing with
sum([1, 3, 8]) = 1 + sum([3, 8])
). If we try to call this
function (you can just copy paste into the console of your browser to
try it out) we get the following error:
sum([1, 3, 8]);
// -> Uncaught RangeError: Maximum call stack size exceeded
This is a common error that arises when recursion goes wrong: we
didnāt tell the function to stop calling itself at any point,
and so itāll keep calling sum
on the tail
(nums
), even when nums
is an empty array.
To see what that means, letās add a console.log
in there
and peek at the results.
const sum = (arr) => {
const [num, ...nums] = arr;
console.log({ arr, num, nums });
return num + sum(nums);
; }
Now if we call it
sum([1, 3, 8]);
weāll get this printed to the console:
arr: [ 1, 3, 8 ], num: 1, nums: [ 3, 8 ] }
{ arr: [ 3, 8 ], num: 3, nums: [ 8 ] }
{ arr: [ 8 ], num: 8, nums: [] }
{ arr: [], num: undefined, nums: [] }
{ arr: [], num: undefined, nums: [] }
{ arr: [], num: undefined, nums: [] }
{ arr: [], num: undefined, nums: [] }
{ arr: [], num: undefined, nums: [] }
{ arr: [], num: undefined, nums: [] }
{ ... (and so on, ad nauseam, until the stack overfloweth)
We probably wanted to stop once we got down to the empty array, but as you can see, our function keeps destructuring and gets a new empty array and calls itself with that, and so on. We need some way of putting a stop to this!
The base caseāan āoff switchā for the recursive call
Letās introduce a base case, which is just a condition that, once met, will give us a way of opting out of the recursive call.
const sum = (arr) => {
if (arr.length === 0) return;
const [num, ...nums] = arr;
return num + sum(nums);
; }
This should stop calling sum
once weāve reached the
empty array []
(in other words, when
arr.length === 0
) and at that point just
return
āsounds good, right? Letās give it a go:
sum([1, 3, 8]);
// -> NaN
Bummer. Well, at least itās not giving us a stack overflow, so we must be getting closer. Letās reason through how this is evaluating:
sum([1, 3, 8]);
// which we said is equal to
1 + sum([3, 8]);
// which is equal to
1 + 3 + sum([8]);
// which is equal to
1 + 3 + 8 + sum([]);
and sum([])
is our base case, and we said that was equal
toā¦ well we just said we would return
at that point, but
anytime you return
without a value in JavaScript, youāre
implicitly returning undefined
. So ultimately this is
evaluating to
1 + 3 + 8 + undefined;
which is indeed not a number (NaN
). If we want
to return our sum, we need to replace undefined
with a
value that will have no effect on our summation (or addition in
general), and that would be 0
:
const sum = (arr) => {
if (arr.length === 0) return 0;
const [num, ...nums] = arr;
return num + sum(nums);
; }
And now it works:
sum([1, 3, 8]);
// -> 12
Yay! š
A lesson from an iterative
sum
Even though we got our recursive sum
function working,
thereās something that doesnāt feel right about returning 0
on the last callāyouād think youād be returning the result of
your summation. Like, if I were to define sum
iteratively,
I might come up with
const sum = (arr) => {
let summedValue = 0;
for (const num of arr) {
+= num;
summedValue
}return summedValue;
; }
And this feels more intuitive: weāre starting with 0
,
adding each num
as we go, and at the end weāre returning
the total (summedValue
). But, we canāt really do the same
thing in our recursive function. If we were to declare
let summedValue = 0
in our function body, it would just get
reset to 0
on each call. What we can do is pass it
in as a second argument, and initialize it at 0
using ES6
default parameter syntax:
const sum = (arr, summedValue = 0) => {
...
}
And then on the next recursive call we add our num
to
the summedValue
:
return sum(nums, summedValue + num);
and in our base case, we just return the
summedValue
:
if (arr.length === 0) return summedValue;
Altogether it looks like:
const sum = (arr, summedValue = 0) => {
const [num, ...nums] = arr;
if (arr.length === 0) return summedValue;
return sum(nums, summedValue + num);
; }
Paring it down
If we try to refactor this to something more concise, we might get:
const sum = ([num, ...nums], summedValue = 0) =>
=== undefined ? summedValue : sum(nums, summedValue + num); num
If you donāt trust me that this still works, give it the olā
copy-paste into the console.1 Now letās see if we can
use this as a template to define other array functions. Errrā¦
Iām actually just going to go ahead and say that you can in
fact use this as a template for defining other array functions. For
example, if I just change a couple things, Iāve got
reverse
:
const reverse = ([num, ...nums], summedValue = []) =>
=== undefined ? summedValue : reverse(nums, [num, ...summedValue]);
num
reverse([1, 3, 8]);
// -> [8, 3, 1]
Or we can make the higher-order function any
:
const any =
=>
(predFn) , ...nums], summedValue = false) =>
([num=== undefined
num ? summedValue
: any(predFn)(nums, predFn(num) || summedValue);
any(isOdd)([1, 3, 8]);
// -> true
Of course, weāre not limited to talking about num
s here,
and it doesnāt make sense to say that a summedValue
is the
thing weāre returning, so Iām going to do something that you may find
even more offensive (bear with me š») and rename these variables to the
more generic x
and acc
(short for
accumulated value or accumulator):
const sum = ([x, ...xs], acc = 0) => (x === undefined ? acc : sum(xs, acc + x));
const reverse = ([x, ...xs], acc = []) =>
=== undefined ? acc : reverse(xs, [x, ...acc]);
x
const any =
=>
(predFn) , ...xs], acc = false) =>
([x=== undefined ? acc : any(predFn)(xs, predFn(x) || acc); x
Refactoring things to this minimal representation allows us to more
easily see a pattern: notice that in each of these functions, we have
some operation that combines our x
with acc
somehowāwhether addition, concatenation or logical disjunction (the
||
operator)āand weāre initializing the acc
variable with some value that is neutral in regards to that
operationā0
for +
, []
for
concatenation, and false
for ||
.2
Aside from those two unique elements, everything else is repetition that
we should be able to factor out into its own function. Letās call it
fold
:
const fold = ([x, ...xs], acc, foldingFn) =>
=== undefined ? acc : fold(xs, foldingFn(acc, x), foldingFn); x
This may seem like a lot to behold, but all that Iāve done is taken
our template and changed it so that acc
no longer has a
default value (weāll have to leave that to the caller to pass in, since
it depends on what ācombiningā operation they are doing) and weāre also
passing in an extra parameter: a foldingFn
, or folding
function, for lack of a better termāthis is the same thing as the
ācombiningā operation (addition/concatenation/disjunction) expressed as
a function that takes acc
and x
(current
value). Now I can re-write sum
, reverse
, and
any
in terms of fold
:
const sum = (xs) => fold(xs, 0, (acc, x) => x + acc);
const reverse = (xs) => fold(xs, [], (acc, x) => [x, ...acc]);
const any = (predFn) => (xs) => fold(xs, false, (acc, x) => predFn(x) || acc);
This works, but we can do a little better by arranging
fold
so it can be used point free:
const fold =
, acc) =>
(foldingFn, ...xs]) =>
([x=== undefined ? acc : fold(foldingFn, foldingFn(acc, x))(xs); x
This lets us clean up our other functions a little bit:
const sum = fold((acc, x) => x + acc, 0);
const reverse = fold((acc, x) => [x, ...acc], []);
const any = (predFn) => fold((acc, x) => predFn(x) || acc, false);
fold
unmasked
Turns out fold
is something we already know and love in
JavaScriptāitās just reduce
!3
const reduce =
, acc) =>
(reducer, ...xs]) =>
([x=== undefined ? acc : reduce(reducer, reducer(acc, x))(xs); x
Granted itās a standalone version, not a method on the
Array
prototype, but it works just the same. And we can
define other array functions than just sum
,
reverse
and any
with it; in fact,
any array transformation can be defined in terms of
reduce
(thatās an open challenge!), which is pretty
powerful stuff.
Big words to google
Whew, this has been a doozy of a post, and Iāve pretty much
exhausted my knowledge about this stuff so I canāt say much more with
certainty, but if youāre interested in learning more Iād say it might be
worthwhile looking into how the reduce
/fold
function is generalized by the concept of a catamorphism (which
is one of many recursion schemes).4 Of
course reduce
doesnāt have to be defined recursively, we
could write it iteratively, itās just an implementation detail,5 but it gives us a way of abstracting
out this very powerful pattern. And I think the power of
recursion schemes in general is that you can abstract out such patterns
not just for arrays but for any data structure (like linked lists,
trees, maps, etc.)ābut, not sure yet. To be continuedā¦ šµļøāāļø
Erratum: Itās been pointed out to me that
fold
does not work exactly like
reduce
, in that the initialValue
is optional
in the latter. A more faithful recursive version might be written:
const fold = (foldingFn, acc) => ([x, ...xs]) =>
!== undefined
acc ? x === undefined
? acc
: fold(foldingFn, foldingFn(acc, x))(xs)
: fold(foldingFn, x)(xs)
Notice that Iām doing the destructuring straightaway as part of the function call, which means Iāve lost my way to reference the whole array (
arr
is nowhere to be found) and so Iām checking for the head (num
) to beundefined
, which strictly speaking is not the same as the array having length0
āif youāre working with a sparsely-defined array (like,[1, , 8]
or[1, undefined, 8]
) this wonāt workābut for our purposes weāll pretend theyāre equivalent. š¤«ā©ļøIf I add
0
to any number, Iāll just get that number back. If I concatenate[]
to any array, Iāll just get that array back. If I sayx || false
wherex
is some boolean, Iāll just getx
back.ā©ļøThough it is often called
fold
in other languages.ā©ļøA catamorphism is described as a function that ādestruct[s] a listā or other data structure (in other words, a function from a structure containing type A to a value of type B). See āFunctional Programming with Bananas, Lenses, Envelopes and Barbed Wireā.ā©ļø
Hope you donāt feel cheated by my mentioning that in the last paragraph!ā©ļø