Junior programmers often begin by learning loops as a foundational programming construct. However, loops can introduce complexities and potential issues in software development, and sometimes they aren't necessary.
You might ask, "Is this just another debate between loops and recursion?" Not exactly. Neither approach is always ideal. Even "reduce" is somewhat low-level, and functional developers tend to use it sparingly. Still, it's important to understand where these higher-order functions come from.
Loops take many forms: "for," "forEach," "while," "do," "for...of," and "for...in." While some argue that array methods like "map" or "reduce" rely on loops under the hood, that's beside the point for now. Here, we'll focus on creating our own recursive functions. In practice, you'd likely use libraries or built-in array methods, but building from scratch helps you understand the core concepts, even if it's not the most efficient approach. If you're worried about performance, don't worry—I'll address that soon.
In JavaScript, trade-offs are everywhere. There's a constant balancing act between writing performant code, code that's easy to maintain and understand, and code that's inherently correct. Finding the right balance can be tough, and it's often the source of debate in pull requests.
Simple English: No bugs
Loops present four primary challenges: Off-by-one errors, Infinite loops, statefulness, and concealed intent. It could be argued that loops like for...in
may not suffer from Off-by-one errors, but they still introduce statefulness and can obscure the underlying intent. Recursion, while an alternative, also carries its own set of challenges, including some of these same issues.
Simple English: No refactoring / Only change the code that's corresponding to the changes of the business logic
Many developers find themselves frustrated when faced with changing requirements because they've invested substantial time in crafting high-performance, bug-free code. Adapting to new requirements entails significant code restructuring and updating unit tests. The flexibility to freely move loops within your codebase is often limited due to the potential for side effects or mutations. In some cases, large and nested loops become necessary for performance optimization, but they can introduce uncontrolled side effects, potentially violating the rule of least power.
Languages like Haskell employ a concept called fusion to effectively "merge" iterations, promoting more efficient code. Embracing the philosophy of wholemeal programming offers a valuable pattern for making code modular and reusable, facilitating easier adaptation to changing requirements.
Creating highly performant code with loops is indeed possible, but it raises important questions: Will that code remain performant when requirements change? Can your high-performance code be comprehended easily by your colleagues? Does it retain its performance after refactoring? On a larger scale, manual optimization often leads to reduced code reusability, diminished modularity, and increased complexity in components. This complexity makes the code harder to grasp and testing for correctness becomes a daunting task.
Remember, your code will inevitably evolve and will be read and maintained by your fellow developers. If you're writing throwaway code, concerns about code quality might not seem worthwhile.
Hence, achieving a balance among the three elements of the triangle is crucial. In modern engineering teams, the prevailing wisdom suggests that 95% of the time, you should prioritize correctness and ergonomics over raw performance. Today's computers are generally fast enough, and premature optimization is often discouraged. However, replacing loops with alternative constructs can come at a significant performance cost, potentially leading to stack overflows in certain scenarios.
While all three aspects—performance, correctness, and ergonomics—are equally important, this article primarily emphasizes the significance of correctness and ergonomics. In real-world projects, you'll need to rely on your expertise to strike the right trade-offs. If you're interested in exploring a language that strives not to compromise on any of these aspects, take a look at Haskell. It has been meticulously designed to excel in functional programming, offering high performance.
We do expect some basic familiarity with functional programming concepts, and there are numerous additional resources available online, covering topics like why ternaries are used instead of if
statements, the importance of avoiding variable mutations, and discussions on complexity and tail call optimization (TCO), among others.
Feel free to contribute additional insights and items to this discussion.
Recursions embrace a declarative approach, while loops adhere to the imperative paradigm. Let's illustrate this difference by asking for a list of even numbers, specifically: [2, 4, 6, 8, 10]
.
In the imperative world, I'd provide you with a sequence of explicit steps. Start with the first number in the list, divide it by two, check if the remainder is zero, perform an action accordingly, then proceed to the next number, and so on. Essentially, it's a series of ordered instructions, akin to a loop. This approach introduces a statefulness problem, which we'll delve into shortly.
On the declarative side of things, I'd simply state my requirement: "Give me all the even numbers." To further clarify, I define even numbers as those divisible by 2 with a remainder of 0. In essence, I'm not instructing you on how to determine whether a number is even; I'm merely providing a definition. There's no need for me to specify the process or maintain any state during this operation.
In summary, declarative programming excels at expressing what you want, leaving the "how" to be abstracted away. Imperative programming, on the other hand, spells out the detailed steps for achieving a goal, often leading to issues related to statefulness. For more insights on why Haskell, a declarative language, doesn't include traditional loops like "for" or "while," you can refer to this Quora answer.
—Tikhon Jelvis, lead data scientist at Target working on supply chain optimization and simulation
—Mark Sheldon, Lecturer in Computing at Tufts University
Avoid The One-off Problem, Infinite Loops, Statefulness and Hidden intent.
—JOAB JACKSON, Managing Editor at The New Stack University
Nested loops,
continue
,break
andgoto
are clever tricks to trap you. They are confusing and unmaintainable.
—Well, this one is kinda common sense :)
If you are still writing loops, you’re not a bad person. Just think about whether you need to write loops or if there’s a better alternative. Loops are best executed at the CPU level, well-beneath the concerns of most developers.
—Marco Emrich, Software crafter, web dev, code coach, code retreat facilitator, author, consultant
There's a rule in eslint-plugin-fp. There are also many other useful rules in the plugin so please do check them out!
Name | Off-by-one error | Infinite loop | Statefulness | Hidden intent |
---|---|---|---|---|
Loops | Yes 😱 | Yes 😱 | Yes 😱 | Yes 😱 |
Iterables | NO 💚 | NO 💚 | Yes 😱 | Yes 😱 |
Recursion (Without higher-order functions) | NO 💚 | Yes 😱 | NO 💚 | Yes 😱 |
Recursion (With higher-order functions) | NO 💚 | NO 💚 | NO 💚 | NO 💚 |
Corecursion | NO 💚 | NO 💚 | NO 💚 | NO 💚 |
Transducers | NO 💚 | NO 💚 | NO 💚 | NO 💚 |
Monoids | NO 💚 | NO 💚 | NO 💚 | NO 💚 |
F-Algebras | NO 💚 | NO 💚 | NO 💚 | NO 💚 |
Name | Iteration | Transformation | Accumulation |
---|---|---|---|
Loops | ✔ | ✔ | ✔ |
Recursion | ✔ | ✔ | ✔ |
Corecursion | ✔ | ✔ | ✔ |
Transducers | ✔ | ✔ | ✖ |
Monoids | ✔ | ✖ | ✔ |
F-Algebras | ✖ | ✔ | ✔ |
You can immediately avoid off-by-one error and state by using recursions.
Let's define some helper functions:
const first = xs => xs[0]
const rest = xs => xs.slice(1)
NOTE: functions like this could be defined with reduce
too, but you can easily hit stack overflow. For all intensions and purposes let's use existing array methods.
const sum = xs =>
xs.length === 0
? 0
: first(xs) + sum(rest(xs));
const reverse = xs =>
xs.length === 0
? []
: reverse(rest(xs)).concat(first(xs));
const sum = list => {
const go = (acc, xs) =>
xs.length === 0
? acc
: go(acc + first(xs), rest(xs));
return go(0, list)
}
const reduce = (f, acc, xs) =>
xs.length === 0
? acc
: reduce(f, f(acc, first(xs)), rest(xs));
NOTE: Since tail call optimization is currently only supported by Safari, tail recursion may cause stack overflow in most other JavaScript environments. While others, such as the Chrome devs, appear to be discussing the subject on-and-off, you may wish to, in this case, use a loop here to compromise (and this is an example of balancing the triangle):
const reduce = function(reduceFn, accumulator, iterable){
for (let i of iterable){
accumulator = reduceFn(accumulator, i)
}
return accumulator
}
Recursion is too low-level. Not low-level in the sense of direct access to the machine but low-level in the sense of language design and abstraction. Both loops and recursions do a poor job of signalling intent. This is where higher-order functions come in. Map, filter, fold and friends package up common recursive patterns into library functions that are easier to use than direct recursion and signal intent.
const sum = xs =>
reduce((acc, x) => x + acc, 0, xs)
sum([1,2,3])
// => 6
const reverse = xs =>
reduce((acc, x) => [x].concat(acc), [], xs)
const map = (f, xs) =>
reduce((acc, x) => acc.concat(f(x)), [], xs)
const filter = (f, xs) =>
reduce((acc, x) => f(x) ? acc.concat(x) : acc, [], xs)
const all = xs =>
reduce((acc, x) => acc && x, true, xs)
const any = xs =>
reduce((acc, x) => acc || x, false, xs)
NOTE: The following sections are considered somewhat advanced. You don't have to understand all the details of the jargons, but rather get an overall intuition on how you could abstract things so that they can compose well. You can start learning it here. This course is widely recommended by Haskell learners.
const para = (f, acc, xs) =>
xs.length === 0
? acc
: para(f, f(acc, first(xs), xs), rest(xs));
const unfold = (f, seed) => {
const go = (f, seed, acc) => {
const res = f(seed);
return res ? go(f, res[1], acc.concat([res[0]])) : acc;
}
return go(f, seed, [])
}
unfold(x =>
x < 26
? [String.fromCharCode(x + 65), x + 1]
: null
, 0);
//=> [A,B,C,D,E,F,G,H,I,J,K,L,M,N,O,P,Q,R,S,T,U,V,W,X,Y,Z]
const range = (i, count) =>
unfold(x => (x <= count)
? [x, x+1]
: null
, i);
range(5, 10)
//=> [ 5, 6, 7, 8, 9, 10 ]
const Nil = {}
const _Cons = function(h, tl) {
this.head = h;
this.tail = tl;
};
const Cons = (h, tl) =>
new _Cons(h, tl)
const fold = (f, acc, xs) =>
xs.head
? fold(f, f(acc, xs.head), xs.tail)
: acc
const lst = Cons(3, Cons(4, Cons(5, Nil)));
fold((acc, x) => acc + x, 0, lst)
//=> 12
const Empty = {}
const _Leaf = function(x) { this.x = x; }
const Leaf = x => new _Leaf(x)
const _Node = function(l, x, r) {
this.left = l;
this.x = x;
this.right = r;
}
const Node = (l, x, r) => new _Node(l, x, r)
const tree = Node(Node(Leaf(2), 1, Leaf(3)), 0, Leaf(4))
fold((acc, x) => acc + x, 0, tree) // Try to implement `fold` yourself
//=> 10
Adding stateful transducers and grouping operations.
Helper functions:
const concat = (a, b) => a.concat(b)
const mapper = (f, cnct) => (acc, x) =>
cnct(acc, f(x))
reduce(mapper(x => x + 1, concat), [], [1,2,3])
//=> [2,3,4]
const filterer = (f, cnct) => (acc, x) =>
f(x) ? cnct(acc, x) : acc
reduce(filterer(x => x > 1, concat), [], [1,2,3])
//=> [2,3]
reduce(
filterer(x => x > 1,
mapper(x => x + 1, concat)),
[], [1,2,3]
)
//=> [3,4]
// Try to implement append yourself
reduce(filterer(x => x > 1,
mapper(x => x + 1, append)),
Nil, Cons(1, Cons(2, Cons(3, Nil))))
//=> [3,4]
// Try to implement insert yourself
reduce(filterer(x => x > 1,
mapper(x => x + 1, insert)),
Empty, Node(Node(Leaf(2), 1, Leaf(3)), 0, Leaf(4)))
//=> [3,4]
Iteration ✔ | Transformation ✔ | Accumulation ✖
Helper functions:
const fold = xs =>
xs.length
? first(xs).concat(fold(rest(xs)))
: empty
const _Sum = function(x) { this.val = x }
const Sum = x => new _Sum(x)
_Sum.prototype.concat = y =>
Sum(this.val + y.val)
_Sum.prototype.empty = () => Sum(0)
const empty = _Sum.prototype.empty()
fold([Sum(1), Sum(2), Sum(3), Sum(4)])
//=> Sum(10)
const _Product = function(x) { this.val = x }
const Product = x => new _Product(x)
_Product.prototype.concat = y => Product(this.val * y.val)
_Product.prototype.empty = () => Product(1)
const empty = _Product.prototype.empty()
fold([Product(1), Product(2), Product(3), Product(4)])
//=> Product(24)
const _Max = function(x) { this.val = x }
const Max = x => new _Max(x)
_Max.prototype.concat = function(y){
return Max(this.val > y.val ? this.val : y.val)
}
_Max.prototype.empty = () => Max(-Infinity)
const empty = _Max.prototype.empty()
fold([Max(11), Max(16), Max(3), Max(9)])
//=> Max(16)
const _All = function(x) { this.val = x }
const All = x => new _All(x)
_All.prototype.concat = function(y){
return All(this.val && y.val)
}
_All.prototype.empty = () => All(true)
const empty = _All.prototype.empty()
fold([All(false), All(false), All(true), All(false)])
//=> All(false)
const _Any = function(x) { this.val = x }
const Any = x => new _Any(x)
_Any.prototype.concat = function(y){
return Any(this.val || y.val)
}
_Any.prototype.empty = () => Any(false)
const empty = _Any.prototype.empty()
fold([Any(false), Any(false), Any(true), Any(false)])
//=> Any(true)
Iteration ✔ | Transformation ✖ | Accumulation ✔
const cata = (f, xs) =>
f(xs.map(ys => cata(f,ys)))
Nil.map = f => Nil
_Cons.prototype.map = function(f) {
return Cons(this.head, f(this.tail))
}
const sum = (x) =>
(x === Nil) ? 0 : x.head + x.tail
const lst = Cons(2, Cons(3, Cons(4, Nil)));
cata(sum, lst);
//=> 9
const map = (f, xs) =>
cata(x => (x == Nil) ? Nil : Cons(f(x.head), x.tail), xs)
map(x => x + 1, Cons(2, Cons(3, Cons(4, Nil))))
//=> Cons(3, Cons(4, Cons(5, Nil)))
Empty.map = f => Empty
_Leaf.prototype.map = function(f) {
return Leaf(this.x)
}
_Node.prototype.map = function(f) {
return Node(f(this.left), this.x, f(this.right))
}
const tr = Node(Node(Leaf(2), 1, Leaf(3)), 0, Leaf(4))
cata(t =>
t.constructor === _Node
? t.left + t.x + t.right
: t.constructor === _Leaf
? t.x
: 0
, tr)
//=> 10
const ana = (g, a) => g(a).map(x => ana(g, x))
const arrToList = xs =>
xs.length === 0 ? Nil : Cons(first(xs), rest(xs))
ana(arrToList, [1, 2, 3, 4, 5])
//=> Cons(1, Cons(2, Cons(3, Cons(4, Cons(5, Nil)))))
const makeAlphabet = x =>
x > 25
? Nil
: Cons(String.fromCharCode(x + 65), x + 1)
ana(makeAlphabet, 0)
//=> Cons(A, Cons(B, Cons(C, Cons(D, Cons(E, Cons(F, Cons(G, Cons(H...
const range = (acc, count) =>
ana(x => (x >= count) ? Nil : Cons(x, x + 1), acc)
range(2, 10)
//=> Cons(2, Cons(3, Cons(4, Cons(5, Cons(6, Cons(7, Cons(8, Cons(9, Nil))))))))
const _Const = function(val) { this.val = val }
const Const = x => new _Const(x)
const _Add = function(x, y) {
this.x = x;
this.y = y;
}
const Add = (x, y) => new _Add(x, y)
const _Mul = function(x, y) {
this.x = x
this.y = y
}
const Mul = (x, y) => new _Mul(x, y)
_Const.prototype.map = function(f) { return this }
_Add.prototype.map = function(f) {
return Add(f(this.x), f(this.y))
}
_Mul.prototype.map = function(f) {
return Mul(f(this.x), f(this.y))
}
const interpret = a =>
a.constructor === _Mul
? a.x * a.y
: a.constructor === _Add
? a.x + a.y
: /* a.constructor === _Const */ a.val
const program = Mul(Add(Const(2), Const(3)), Const(4))
cata(interpret, program);
//=> 20
const _Concat = function(v, next) {
this.val = v;
this.next = next;
}
const Concat = (v, x) => new _Concat(v, x)
const _Replace = function(v, x, next) {
this.val = v;
this.x = x;
this.next = next;
}
const Replace = (v, x, nt) => new _Replace(v, x, nt)
const _Input = function(v) { this.val = v }
const Input = v => new _Input(v)
_Concat.prototype.map = function(f) {
return Concat(this.val, f(this.next))
}
_Replace.prototype.map = function(f) {
return Replace(this.val, this.x, f(this.next))
}
_Input.prototype.map = function(f) {
return Input(this.val)
}
const interpret = t =>
t.constructor === _Concat
? t.next.concat(t.val)
: t.constructor === _Replace
? t.next.replace(t.val, t.x)
: /* t.constructor === _Input */ t.val
const prog = Concat("world", Replace("h", "m", Input("hello")))
cata(interpret, prog)
//=> melloworld
const interpret1 = t =>
t.constructor === _Concat
? "concatting "+t.val+" after "+t.next
: t.constructor === _Replace
? "replacing "+t.val+" with "+t.x+" on "+t.next
: /* t.constructor === _Input */ t.val
const prog = Concat("world", Replace("h", "m", Input("hello")))
cata(interpret1, prog)
//=> concatting world after replacing h with m on hello
Iteration ✖ | Transformation ✔ | Accumulation ✔
MIT