Elixir Unexpected Floating Point Result Difference Between Enum.Sum and My Custom Sum Function

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.

Upvotes: 3

Views: 385

Answers (2)

Adam Millerchip
Adam Millerchip

Reputation: 23129

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.

Upvotes: 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.

Upvotes: 3

Related Questions