Elixir 意外的浮点结果 Enum.Sum 和我的自定义 Sum 函数之间的差异

Posted

技术标签:

【中文标题】Elixir 意外的浮点结果 Enum.Sum 和我的自定义 Sum 函数之间的差异【英文标题】:Elixir Unexpected Floating Point Result Difference Between Enum.Sum and My Custom Sum Function 【发布时间】:2019-07-30 17:02:34 【问题描述】:

作为作业,我实现了以下sum 函数来返回数字列表的总和:

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

作为一个单元测试,我使用了以下比较:

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

这个测试失败,返回false。当我在iex 中运行函数时,我得到了以下结果,这让我很惊讶:

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

此外,这两个函数始终分别生成-3.1524700989447307-3.1524700989447303

为什么会出现这种差异?

编辑

Why does changing the sum order returns a different result? 的问题为我指明了正确的方向,但我认为这个问题的实际答案(OTP 中的实现细节)也可能对某些人感兴趣。

【问题讨论】:

我认为是顺序:[-2, -2.1524700989447303, 1] |> Enum.reverse |> Enum.sum 生成 -3.1524700989447303 @denis.peplin ???这是出乎意料的:D Why does changing the sum order returns a different result?的可能重复 【参考方案1】:

Why does changing the sum order returns a different result?这个问题的答案启发了我去源码看看实现是如何的,当然,当参数是list时,它使用Erlang's implementation of foldl,它按顺序应用函数head + accumulator 而不是我的实现中的 accumulator + head

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.

编辑

@Sneftel 的评论让我做了以下实验:

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

这个新版本和Enum.sum的结果一样:

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

看来是顺序的问题。

编辑 2

a + sum(as) 更改为sum(as) + a 在列表未按相反顺序排列时对结果没有影响。

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

所以当我们谈论“顺序”的相关性时,它是folding 发生的顺序,而不是操作数的顺序。

【讨论】:

区别不在于head+accacc+head。浮点加法是可交换的,所以这两个会产生相同的结果。相反,不同之处在于他们完全使用了蓄能器。如果您还没有了解“尾递归”,那么值得您花时间研究一下,了解为什么 Erlang 的方法比您的方法高效得多。 @Sneftel 我做了一个实验,似乎是关于顺序...不过我肯定会看看“尾递归”,我正在阅读的书是关于这些东西的:) 它与顺序有关,但在左折叠与右折叠的意义上,而不是在操作数顺序的意义上。尝试将a + sum(as) 更改为sum(as) + a,您会发现它对结果没有影响。 @Sneftel 你是对的 :) 。我认为我现在的心态是正确的。【参考方案2】:

我认为这里真正的问题是,在处理浮点数时永远不应该期望做完全相等,因为它们本质上是不精确的。如果需要精度,则可以使用 Decimal 等模块。

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

测试会话:

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 很好地概述了如何有效地处理浮点数。

【讨论】:

以上是关于Elixir 意外的浮点结果 Enum.Sum 和我的自定义 Sum 函数之间的差异的主要内容,如果未能解决你的问题,请参考以下文章

Laravel Elixir Browserify 失败!:意外令牌 - 使用 VueJs

在 Elixir 中传递和使用命名函数?

JavaScript为啥浮点数会丢失精度

动手动脑

在 Elixir 中将整数列表打印为字符串

简单Elixir游戏服设计- 创建项目