I have 2 for
loops that work well to create a grid with rows and columns, but I would like to improve the solution using recursion as it is more cleaner and recommended (in Functional Programming).
The desired output is a single array of pairs used in css grid
const createGrid = (rows,columns) => {
let grid=[]
for (let y = 3; y <= rows; y += 2){
let row = []
for (let x = 3; x <= columns; x += 2){
let col = [y, x]
row = [...row,col]
}
grid =[...grid, ...row]
}
return grid
}
Are there also any guidelines as to how to convert for loops to recursion solutions when possible?
At first, on reading your code, I thought you generated one style of grid, so that makeGrid (7, 9)
would result in something like this:
[
[[3, 3], [3, 5], [3, 7], [3, 9]],
[[5, 3], [5, 5], [5, 7], [5, 9]],
[[7, 3], [7, 5], [7, 7], [7, 9]]
]
Instead, it returns a single array of pairs:
[[3, 3], [3, 5], [3, 7], [3, 9], [5, 3], [5, 5], [5, 7], [5, 9], [7, 3], [7, 5], [7, 7], [7, 9]]
I'm pretty sure I'm not the only one. Bergi suggested a fix in the comments to change it to the former. (That's what changing grid =[...grid, ...row]
to grid =[...grid, row]
would do.) And the wonderful answer from Thankyou is predicated on the same assumption.
This is a problem.
When the reader can't quickly understand what your code does, it becomes much harder to maintain... even for yourself just a few weeks later.
The reason you may hear advice to replace loops with recursion is related to this. Loops are all about explicit imperative instructions to get what you want, depending on mutating variables, which then you have to keep track of, and easily subject to off-by-one errors. Recursion is usually more declarative, a way of saying that the result you're looking for is just a matter of combining these simpler results with our current data, and pointing out how to get the simpler results, through either a base case or a recursive call.
The advantage in readability and understandability, though, is the key, not the fact that the solution is recursive.
Don't get me wrong, recursion is one of my favorite programming techniques. The answer from Thankyou is beatiful and elegant. But it's not the only technique which will fix the problems that explicit for
-loops present. Usually one of the first things I do when trying to move junior programmer to intermediate and beyond is to replace for
-loops with more meaningful constructs. Most loops are trying to do one of a few things. They're trying to convert every element of a list into something new (map
), trying to choose some important subset of the elements (filter
), trying to find the first important element (find
), or trying to combine all the elements into a single value (reduce
). By using these instead, the code become more explicit.
Also important, as seen in the answer from Thankyou, is splitting out reusable pieces of the code so that your main function can focus on the important parts. The version below extracts a function rangeBy
, which adds a step
parameter to my usual range
function. range
creates a range of integers so that, for instance, range (3, 12)
yields [3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
rangeBy
adds an initial step
parameter, so that range (2) (3, 12)
yields [3, 5, 7, 9, 11]
.
We use that rangeBy
function along with a map
, and its cousin, flatMap
to make a more explicit version of your looped function:
const rangeBy = (step) => (lo, hi) =>
[... Array (Math .ceil ((hi - lo + 1) / step))]
.map ((_, i) => i * step + lo)
const createGrid = (rows, columns) =>
rangeBy (2) (3, rows) .flatMap (y =>
rangeBy (2) (3, columns) .map (x =>
[y, x]
)
)
console .log (createGrid (7, 9))
Knowing what rangeBy
does, we can mentally read this as
const createGrid = (rows, columns) =>
[3, 5, 7, ..., rows] .flatMap (y =>
[3, 5, 7, ..., columns] .map (x =>
[y, x]
)
)
Note that if you want the behavior I was expecting, you can achieve it just by replacing flatMap
with map
in createGrid
. Also, if you do so, it's trivial to add the more generic behavior that Thankyou offers, by replacing [y, x]
with f (x, y)
and passing f
as a parameter. What remains hard-coded in this version is the conversion of rows
and columns
into arrays of odd numbers starting with 3. We could make the actual arrays the arguments to our function, and applying rangeBy
outside. But at that point, we're probably looking at a different function ideally named cartesianProduct
.
So recursion is an amazing and useful tool. But it's a tool, not a goal. Simple, readable code, however, is an important goal.
I meant to mention this originally and simply forgot. The following version demonstrates that the currying in rangeBy
is far from fundamental. We can use a single call easily:
const rangeBy = (step, lo, hi) =>
[... Array (Math .ceil ((hi - lo + 1) / step))]
.map ((_, i) => i * step + lo)
const createGrid = (rows, columns) =>
rangeBy (2, 3, rows) .flatMap (y =>
rangeBy (2, 3, columns) .map (x =>
[y, x]
)
)
console .log (createGrid (7, 9))
The main rationale for currying rangeBy
is that when it's written like this:
const rangeBy = (step) => (lo, hi) =>
[... Array (Math .ceil ((hi - lo + 1) / step))]
.map ((_, i) => i * step + lo)
we can write the more common range
by simply applying 1
to the above. That is,
const range = rangeBy (1)
range (3, 12) //=> [3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
This is so useful that it's become my usual style for writing functions. But it is not a significant part of the simplification of your problem.