Awesome
You don't need loops :loop:
Prerequisites
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.
Correctness by construction
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.
Ergonomics and maintainability
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.
Runtime performance
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.
Imperative vs. Declarative: A Paradigm Clash
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]
.
Imperative Perspective
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.
Declarative Approach
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.
Comparison
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.
Voice of Developers
—<cite>Tikhon Jelvis, lead data scientist at Target working on supply chain optimization and simulation</cite>
—<cite>Mark Sheldon, Lecturer in Computing at Tufts University</cite>
Avoid The One-off Problem, Infinite Loops, Statefulness and Hidden intent.
—<cite>JOAB JACKSON, Managing Editor at The New Stack University</cite>
Nested loops,
continue
,break
andgoto
are clever tricks to trap you. They are confusing and unmaintainable.
—<cite>Well, this one is kinda common sense :)</cite>
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.
—<cite>Marco Emrich, Software crafter, web dev, code coach, code retreat facilitator, author, consultant</cite>
ESLint Plugin
There's a rule in eslint-plugin-fp. There are also many other useful rules in the plugin so please do check them out!
Potential problems :imp:
Name | Off-by-one error | Infinite loop | Statefulness | Hidden intent |
---|---|---|---|---|
Loops | Yes :scream: | Yes :scream: | Yes :scream: | Yes :scream: |
Iterables | NO :green_heart: | NO :green_heart: | Yes :scream: | Yes :scream: |
Recursion (Without higher-order functions) | NO :green_heart: | Yes :scream: | NO :green_heart: | Yes :scream: |
Recursion (With higher-order functions) | NO :green_heart: | NO :green_heart: | NO :green_heart: | NO :green_heart: |
Corecursion | NO :green_heart: | NO :green_heart: | NO :green_heart: | NO :green_heart: |
Transducers | NO :green_heart: | NO :green_heart: | NO :green_heart: | NO :green_heart: |
Monoids | NO :green_heart: | NO :green_heart: | NO :green_heart: | NO :green_heart: |
F-Algebras | NO :green_heart: | NO :green_heart: | NO :green_heart: | NO :green_heart: |
Limitations
Name | Iteration | Transformation | Accumulation |
---|---|---|---|
Loops | ✔ | ✔ | ✔ |
Recursion | ✔ | ✔ | ✔ |
Corecursion | ✔ | ✔ | ✔ |
Transducers | ✔ | ✔ | ✖ |
Monoids | ✔ | ✖ | ✔ |
F-Algebras | ✖ | ✔ | ✔ |
Quick Links
Recursion
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.
Sum
const sum = xs =>
xs.length === 0
? 0
: first(xs) + sum(rest(xs));
Reverse
const reverse = xs =>
xs.length === 0
? []
: reverse(rest(xs)).concat(first(xs));
Tail recursive sum
const sum = list => {
const go = (acc, xs) =>
xs.length === 0
? acc
: go(acc + first(xs), rest(xs));
return go(0, list)
}
Reduce
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
}
Higher-order functions
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.
Sum
const sum = xs =>
reduce((acc, x) => x + acc, 0, xs)
sum([1,2,3])
// => 6
Reverse
const reverse = xs =>
reduce((acc, x) => [x].concat(acc), [], xs)
Map
const map = (f, xs) =>
reduce((acc, x) => acc.concat(f(x)), [], xs)
Filter
const filter = (f, xs) =>
reduce((acc, x) => f(x) ? acc.concat(x) : acc, [], xs)
All
const all = xs =>
reduce((acc, x) => acc && x, true, xs)
Any
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.
Paramorphism
const para = (f, acc, xs) =>
xs.length === 0
? acc
: para(f, f(acc, first(xs), xs), rest(xs));
Corecursion
Unfold
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]
Range
const range = (i, count) =>
unfold(x => (x <= count)
? [x, x+1]
: null
, i);
range(5, 10)
//=> [ 5, 6, 7, 8, 9, 10 ]
Linked list
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
Tree
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
Transducers
Adding stateful transducers and grouping operations.
Helper functions:
const concat = (a, b) => a.concat(b)
Map
const mapper = (f, cnct) => (acc, x) =>
cnct(acc, f(x))
reduce(mapper(x => x + 1, concat), [], [1,2,3])
//=> [2,3,4]
Filter
const filterer = (f, cnct) => (acc, x) =>
f(x) ? cnct(acc, x) : acc
reduce(filterer(x => x > 1, concat), [], [1,2,3])
//=> [2,3]
Filter and Map
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 ✖
Monoids
Helper functions:
const fold = xs =>
xs.length
? first(xs).concat(fold(rest(xs)))
: empty
Sum
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)
Product
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)
Max
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)
All
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)
Any
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 ✔
F-Algebras
Catamorphism
const cata = (f, xs) =>
f(xs.map(ys => cata(f,ys)))
Sum
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
Map
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
Anamorphism
const ana = (g, a) => g(a).map(x => ana(g, x))
arrToList
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)))))
makeAlphabet
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...
range
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))))))))
Real-world examples
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 ✔
Inspired by:
License
MIT