Reduce Anti-Patterns

July 12, 2023

I mentioned in Reduce is Not the Answer how:

Very loosely, I believe that “loop constructs” fall somewhere on this abstraction scale:

loop abstraction scale

Exact placement is subjective; this is a starting point. As for ?? and beyond, it might correspond to well-named functions with custom looping logic. For example, mean and stdev would fit the bill. However, these function quickly become less generic.

If I missed something to the right, let’s talk 😄

With this post, I want to sketch typical reduce patterns and identify their higher-level alternatives.

Disclaimers

I initially added a comment to almost all sections:

This is usually combined with other logic, to “save the extra loop”.

Yes, that’s usually how it goes.

Enum.map 🔗

Usual shape:

Enum.reduce(collection, [], fn item, acc ->
  [item.value | acc]
end)
|> Enum.reverse()

Giveaways:

Alternative:

Enum.map(collection, transformFun)

Comments:

Not often directly found in the wild.

Enum.filter / Enum.reject 🔗

Usual shape:

Enum.reduce(collection, [], fn item, acc ->
  if condition do
    [item | acc]
  else
    acc
  end
end)
|> Enum.reverse()

Giveaways:

Alternative:

Enum.filter(collection, predicateFun)
# or, depending on the exact logic
Enum.reject(collection, predicateFun)

Comments:

Probably combined with a map.

Enum.find 🔗

Usual shape:

Enum.reduce_while(collection, nil, fn item, acc ->
  if condition do
    {:halt, item}
  else
    {:cont, acc}
  end
end)

Giveaways:

Alternative:

Enum.find(collection, transformFun)

Comments:

It might be driven by an edge case or custom logic.

Enum.all? / Enum.any? 🔗

Usual shape:

Enum.reduce_while(collection, true, fn item, acc ->
  if condition do
    {:cont, acc}
  else
    {:halt, false}
  end
end)

Giveaways:

Alternative:

Enum.all?(collection, predicateFun)
# or, depending on the exact logic
Enum.any?(collection, predicateFun)

Comments:

In some languages (e.g. JavaScript), this was be a later addition. It also goes under various names (some, every…) and it might not be easy to understand what it does and how/when to use it.

Enum.count 🔗

Usual shape:

Enum.reduce(collection, 0, fn item, acc ->
  if condition do
    acc + 1
  else
    acc
  end
end)

Giveaways:

Alternative:

Enum.count(collection, predicateFun)

Comments:

This isn’t always available; in that case, I would go for:

Enum.filter(collection, predicateFun) |> length()

Enum.sum 🔗

Usual shape:

Enum.reduce(collection, 0, fn item, acc ->
  if condition do
    acc + item.value
  else
    acc
  end
end)

Giveaways:

Alternative:

Enum.filter(collection, predicateFun) # if needed
|> Enum.map(transformFun)
|> Enum.sum()

Enum.group_by / Enum.frequencies / Enum.split_with 🔗

Usual shape:

Enum.reduce(collection, %{}, fn item, acc ->
  key = fn.(item)
  if Map.has_key?(acc, key) do
    Map.put(acc, key, [item | Map.get(acc, key)])
  else
    Map.put(acc, key, [item])
  end
end)

Giveaways:

Alternative:

Enum.group_by(collection, keyFun)
# or, depending on the exact logic
Enum.frequencies(collection)
# or, depending on the exact logic
Enum.split_with(collection, predicateFun)

Comments:

This is all the same logic: divide things into groups.

Enum.min / Enum.max 🔗

Usual shape:

Enum.reduce(collection, fn item, acc ->
  if item < acc do
    item
  else
    acc
  end
end)

Giveaways:

Alternative:

Enum.min(collection)
# or, depending on the exact logic
Enum.max(collection)

Discussion

Eventually, more languages and compilers will be able to grab a combination of map-filter-etc and compile it down to one loop.

In the meantime, I’m afraid we will have to live with “optimized” one-loop reduce.

I’m always happy to discuss specific benchmarks regarding why your reduce can’t be a combination of map, filter and other Enum functions.

Discuss on Twitter