The Functional Shape of Loops

Learning to program, it's quite common to come across many resources and code samples that include imperative loops of various sorts (particularly for and while).

Frequently when I see examples that include loops, I cringe. They often include many steps that make them harder to understand because there is so much happening every iteration.

Instead, I much prefer to use a functional approach to iteration. Each step can be converted to an individual function and those functions can be applied to the entire collection to produce a result.

There are several advantages to the functional approach over imperative:

  • Named functions are essentially declarative; they describe what will happen rather than how it will happen.
  • Splitting functionality into individual functions allows for code reuse.
  • Pure functions can easily be tested individually.

While there are a lot of patterns that occur within loops, there are three primary patterns that make up the vast majority of loop functionality:

  • map
  • filter
  • reduce

Map

Loops where a collection of values is transformed into a new collection of values can be represented by a map function.

For example, this scale function uses a simple for..of loop to map numeric values to produce a new set of values that have been scaled by the provided scalar value.

function scale (collection, scalar) {
  const ret = []
  for (const value of collection) {
    ret.push(value * scalar)
  }
  return ret
}

scale([1, 2, 3], 2) // [2, 4, 6]

In more complex loops instead of pushing values to an array you may see simple assignment:

for (let i = 0; i < names.length; i++) {
  const name = names[i]
  const age = ages[i]
  const profession = professions[i]

  const person = new Person(name, age, profession)

  ...do stuff with person...
}

In each of these cases, the body of the for loop can be conceptually replaced by a function call.

This example uses a classical for loop syntax:

function map (collection, fn) {
  const output = []
  for (let i = 0; i < collection.length; i++) {
    const value = collection[i]
    output.push(fn(value, i, collection))
  }
  return output
}

With modern ES6 syntax, this can be greatly simplified using a generator:

function* map (colleciton, fn) {
  let i = 0
  for (const value of collection) {
    yield fn(value, i++)
  }
}

The scale example from above can then be written as:

function scale (collection, scalar) {
  return map(collection, x => x * scalar)
}

scale([1, 2, 3], 2)  // [2, 4, 6]

For a forward-thinking and composable version, map can be a function that returns a generator function:

const map = fn => function* (collection) {
  let i = 0
  for (const value of collection) {
    yield fn(value, i++)
  }
}

This form can then be used with the pipeline operator (|>):

[1, 2, 3] |> map(x => x * 2) // [2, 4, 6]

Filter

Loops where a collection of values is reduced by some sort of boolean check can be represented by a filter function.

For example, this odds function uses a simple for..of loop to filter out even numbers from a collection of numeric values.

function odds (collection) {
  const ret = []
  for (const value of collection) {
    if (value % 2) {
      ret.push(value)
    }
  }
  return ret
}

odds([1, 2, 3, 4, 5]) // [1, 3, 5]

In more complex loops instead of logic continuing within the if statement, you may see if followed by continue. For example, the evens function to compliment the odds function from above might be written as:

function evens (collection) {
  const ret = []
  for (const value of collection) {
    if (value % 2) {
      continue
    }

    ret.push(value)
  }
  return ret
}

evens([1, 2, 3, 4, 5]) // [2, 4]

In each of these cases, the if condition can be conceptually replaced by a function call.

This example uses a classical for loop syntax.

function filter (collection, fn) {
  const output = []
  for (let i = 0; i < collection.length; i++) {
    const value = collection[i]
    if (fn(value, i, collection)) {
      output.push(value)
    }
  }
  return output
}

With modern ES6 syntax, this can be greatly simplified using a generator:

function* filter (collection, fn) {
  let i = 0
  for (const value of collection) {
    if (fn(value, i++)) {
      yield value
    }
  }
}

The odds example from above can then be written as:

function odds (collection) {
  return filter(collection, x => x % 2)
}

odds([1, 2, 3, 4, 5]) // [1, 3, 5]

filter can also work nicely as a function that returns a generator function:

const filter = fn => function* (collection) {
  let i = 0
  for (const value of collection) {
    if (fn(value, i++)) {
      yield value
    }
  }
}

This form can then be used with the pipeline operator:

[1, 2, 3, 4, 5] |> filter(x => x % 2) // [1, 3, 5]

Reduce

Loops where a collection of values is distilled into a single value can be represented by a reduce function.

For example, this sum function uses a simple for..of loop to add a collection of numeric values to produce the total of those values:

function sum (collection) {
  let total = 0
  for (const value of collection) {
    total += value
  }
  return total
}

sum([1, 2, 3]) // 6

In more complex loops you may see any other operator or operation to a variable scoped outside the loop.

For these types of loops, the body of the for loop can be conceptually replaced by a function call.

This example uses a classical for loop syntax:

function reduce (collection, fn, initial) {
  let acc = initial
  for (let i = 0; i < collection.length; i++) {
    const value = collection[i]
    acc = fn(acc, value, i, collection)
  }
  return acc
}

Using a for..of loop will make this function compatible with generators:

function reduce (collection, fn, initial) {
  let acc = initial
  let i = 0
  for (const value of collection) {
    acc = fn(acc, value, i++)
  }
  return acc
}

The sum example from above can then be written as:

function sum (collection) {
  return reduce(collection, (acc, x) => acc + x, 0)
}

sum([1, 2, 3]) // 6

reduce can also be written as a function that returns a function for composition:

const reduce = (fn, initial) => collection => {
  let acc = initial
  let i = 0
  for (const value of collection) {
    acc = fn(acc, value, i++)
  }
  return acc
}

The composable form can then be used with the pipeline operator:

[1, 2, 3] |> reduce((acc, x) => acc + x, 0) // 6

Recognizing these patterns in complex loops should allow you to break them down into series of simple function calls. Keep in mind that these are only three common looping patterns. There are many others that can be used with these to describe complex logic in short, easy-to-read statements.