-
chevron_right
Erlang Solutions: Reduce, Reuse… Refactor: Clearer Elixir with the Enum Module
news.movim.eu / PlanetJabber • 24 April • 4 minutes
“When an operation cannot be expressed by any of the functions in the Enum module, developers will most likely resort to reduce/3.”
From the docs for Enum.reduce/3
In many Elixir applications, I find
Enum.reduce
is used frequently.
Enum.reduce
can do anything, but that doesn’t mean it should. In many cases, other Enum functions are more readable, practically as fast, and easier to refactor.
I would also like to discuss situations that
are
a good fit for
Enum.reduce
and also introduce you to a custom
credo
check I’ve created, which can help you identify places where
Enum.reduce
could be replaced with a simpler option.
Readability
Here are a few common reduce patterns—and their simpler alternatives. For example, here’s something I see quite often:
Enum.reduce(numbers, [], fn i, result -> [i * 10 | result] end)
|> Enum.reverse()
This is a situation that the
Enum.map
function was designed for:
Enum.map(numbers, & &1 * 10)
Perhaps you know about
Enum.map
, but you might see a call to
reduce
like this:
Enum.reduce(numbers, 0, fn number, result -> (number * 2) + result end)
Let me introduce you to
Enum.sum_by
!
Enum.sum_by(numbers, & &1 * 2)
Let’s look at something a bit more complex:
Enum.reduce(numbers, [], fn item, acc ->
if rem(item, 2) == 0 do
[item * 2 | acc]
else
acc
end
end)
|> Enum.reverse()
This is a perfect case for piping together two
Enum
functions:
numbers
|> Enum.filter(& rem(&1, 2) == 0)
|> Enum.map(& &1 * 2)
Another option for this case could even be to use
Enum.flat_map
:
Enum.flat_map(numbers, fn number ->
if rem(number, 2) == 0 do
[number * 2]
else
[]
end
end)
This is a decent option, but while this achieves the purpose of both filtering and mapping in a single pass, it may not be as intuitive for everybody.
Lastly, say you see something like this and think that it would be difficult to improve:
Enum.reduce(invoices, {[], []}, fn invoice, result ->
Enum.reduce(invoice.items, result, fn item, {no_tax, with_tax} ->
if Invoices.Items.taxable?(item) do
tax = tax_for_value(item.amount, item.product_type)
item = Map.put(item, :tax, tax)
if Decimal.equal?(tax, 0) do
{no_tax ++ [item], with_tax}
else
{no_tax, with_tax ++ [item]}
end
else
{no_tax, with_tax}
end
end)
end)
But this is just the same:
invoices
|> Enum.flat_map(& &1.items)
|> Enum.filter(&Invoices.Items.taxable?/1)
|> Enum.map(& Map.put(&1, :tax, tax_for_value(&1.amount, &1.product_type)))
|> Enum.split_with(& Decimal.equal?(&1.tax, 0))
Aside from improving readability, splitting code out into pipes like this can make it easier to see the different parts of your logic. Especially once you’ve created more than a few lines of pipes, it becomes easier to see how I can pull out different pieces when refactoring. In the above, for example, you might decide to create a
calculate_item_taxes
function which takes a list of items and performs the logic of the
Enum.map
line.
Performance
You may have already thought of a counterpoint: when you pipe functions together, you end up creating new lists, which means more work to be done as well as more memory usage (which means more garbage collection). This is absolutely true, and you should be thinking about this!
But I find that 99% of the time, the data I’m working with makes the performance difference negligible. If you find that your code is slow because of the amount of data that you need to process, you might try using the
Stream
module — it has many of the same functions as
Enum
, but works lazily. If that doesn’t work, then by all means, create a
reduce
(and maybe put it into a well-named function)!
As Joe Armstrong said:
“Make it work, then make it beautiful, then if you really, really have to, make it fast.”
For some information about benchmarks that I’ve run to understand this better, see
this analysis and discussion
.
Good Opportunities for
Enum.reduce
Aside from occasional performance reasons,
Enum.reduce
can often be the simplest solution when you want to transform a data structure over a series of steps. For example:
- Build a data structure based on an enumerable (like a fragment in an Ecto query )
- Transform a data structure (like replacing variable references in a string )
Find Cases in Your Own Code with
credo_unnecessary_reduce
Remember that no one pattern works in all cases, so know what tools you have available! If you’d like to quickly find instances for potential improvements in readability, I built a Credo check to help spot where reduce can be swapped for something simpler.
You can drop it into your project and start catching these anti-patterns automatically.
https://github.com/cheerfulstoic/credo_unnecessary_reduce
Simply add it to your
mix.exs
file:
{:credo_unnecessary_reduce, "~> 0.1.0"}
…and then enable it in your
.credo.exs
file:
{CredounnecessaryReduce.Check, []}
The post Reduce, Reuse… Refactor: Clearer Elixir with the Enum Module appeared first on Erlang Solutions .