3

As a homework, I've implemented the following sum function to return the sum of a list of numbers:

defmodule Homework do
  @spec sum(list(number())) :: number()
  def sum(l) do
    case l do
      [] -> 0
      [a | as] -> a + sum(as)
    end
  end
end

And as a unit test I've used the following comparison:

[-2, -2.1524700989447303, 1] |> fn(l) -> Enum.sum(l) === Homework.sum(l) end.()

And this test fails, returning false. When I ran the functions in iex I got the following result, which is surprising for me:

iex(1)> [-2, -2.1524700989447303, 1] |> Enum.sum
-3.1524700989447307
iex(2)> [-2, -2.1524700989447303, 1] |> Homework.sum
-3.1524700989447303

What is more, both functions consistently generate -3.1524700989447307 and -3.1524700989447303, respectively.

Why does this difference happens?

Edit

The question Why does changing the sum order returns a different result? pointed me in the right direction but I think that the actual answer to this question (an implementation detail in OTP) could be interesting to someone as well.

Adam Millerchip
  • 20,844
  • 5
  • 51
  • 74

2 Answers2

3

The answer to this question Why does changing the sum order returns a different result? inspired me go to the source to see how the implementation was and, surely enough, when the parameter is a list it uses Erlang's implementation of foldl, which applies the function in the order head + accumulator instead of accumulator + head like in my implementation:

https://github.com/elixir-lang/elixir/blob/master/lib/elixir/lib/enum.ex

@spec sum(t) :: number
def sum(enumerable) do
  reduce(enumerable, 0, &+/2)
end

@spec reduce(t, any, (element, any -> any)) :: any
def reduce(enumerable, acc, fun) when is_list(enumerable) do
  :lists.foldl(fun, acc, enumerable)
end

https://github.com/erlang/otp/blob/master/lib/stdlib/src/lists.erl

-spec foldl(Fun, Acc0, List) -> Acc1 when
      Fun :: fun((Elem :: T, AccIn) -> AccOut),
      Acc0 :: term(),
      Acc1 :: term(),
      AccIn :: term(),
      AccOut :: term(),
      List :: [T],
      T :: term().

foldl(F, Accu, [Hd|Tail]) ->
    foldl(F, F(Hd, Accu), Tail); %% here!
foldl(F, Accu, []) when is_function(F, 2) -> Accu.

Edit

@Sneftel's comment made me do the following experiment:

@spec sum(list(number())) :: number()
def sum(l) do
  case Enum.reverse(l) do # reversing first
    [] -> 0
    [a | as] -> a + sum(as)
  end
end

This new version has the same result as Enum.sum:

iex(1)> Homework.sum([-2, -2.1524700989447303, 1])
-3.1524700989447307
iex(2)> Enum.sum([-2, -2.1524700989447303, 1])
-3.1524700989447307

So it seems like it is about the order.

Edit 2

Changing a + sum(as) to sum(as) + a makes no difference in the result when the list not in reverse order.

def sum(l) do
  case l do
    [] -> 0
    [a | as] -> sum(as) + a
  end
end

iex(1)> Homework.sum([-2, -2.1524700989447303, 1])
-3.1524700989447303
iex(2)> Enum.sum([-2, -2.1524700989447303, 1])
-3.1524700989447307

So when we talk about the relevance of 'order' it is the order in which folding is happening, not the order of the operands.

  • 1
    The difference isn't `head+acc` versus `acc+head`. Floating-point addition is commutative, so those two would produce the same results. Rather, the difference is that they're using an accumulator at all. If you haven't learned about "tail recursion" yet, it's worth your time to look into that, to understand why Erlang's approach is much, much more efficient than yours. – Sneftel Mar 09 '19 at 10:57
  • @Sneftel I've made an experiment and it seems like it is about the order... I'll definitely look at 'tail recursion' though, the book I'm reading is about those things :) – Marcus Vinícius Monteiro Mar 09 '19 at 11:39
  • 1
    It is about the order, but in the sense of left-folding versus right-folding, not in the sense of the order of operands. Try changing `a + sum(as)` to `sum(as) + a` and you'll see that it has no effect on the result. – Sneftel Mar 09 '19 at 12:00
  • @Sneftel you're right :) . I think I'm in the right mindset now. – Marcus Vinícius Monteiro Mar 09 '19 at 12:25
3

I think the real issue here is that one should never expect to do exact equality when working with floating point numbers, because they are inherently imprecise. If precision is needed, then a module such as Decimal can be used.

defmodule Homework do
  def sum([]), do: Decimal.new(0)
  def sum([a | as]), do: Decimal.add(a, sum(as))
end

Test session:

iex(1)> test = [Decimal.new(-2), Decimal.new("-2.1524700989447303"), Decimal.new(1)]
iex(2)> Homework.sum(test) === :lists.foldl(&Decimal.add/2, Decimal.new(0), test)
true

What Every Programmer Should Know About Floating-Point Arithmetic provides a nice overview of how to effectively work with floating point numbers.

Adam Millerchip
  • 20,844
  • 5
  • 51
  • 74