为什么 $((0.1)) 在 zsh 中会扩展为 0.10000000000000001?

为什么 $((0.1)) 在 zsh 中会扩展为 0.10000000000000001?

zsh

$ echo $((0.1))
0.10000000000000001

而在其他带有浮点算术扩展的 shell 中:

$ ksh93 -c 'echo $((0.1))'
0.1
$ yash -c 'echo $((0.1))'
0.1

或者awk

$ awk 'BEGIN{print 0.1 + 0}'
0.1

为什么?


这是一个后续聊天讨论

答案1


长话短说

zshdouble选择用于评估浮点算术的二进制数的十进制表示形式,从而完全保留其信息,可以安全地重新输入到其算术表达式中。这是以牺牲化妆品为代价实现的。为此,它需要 17 位有效数字,并确保扩展始终包含 a.左右e,以便在重新输入时将其视为浮点数。

这种“全精度”十进制表示可以被视为二进制double精度机器数字和人类可读数字之间的中间格式。一种中间格式,所有理解浮点数十进制表示形式的工具都可以理解。

在算术表达式中使用 0.1 的情况下,最接近 0.1 的双精度二进制数的最接近的 17 位十进制表示形式是 0.10000000000000001,这是由双精度数的精度限制和舍入造成的假象。

其他 shell 在外观方面享有特权,并且在转换为十进制时确实会丢失一些信息(尽管仍然尝试在附加约束内保留尽可能多的精度)。两种方法都有其优点和缺点,详细信息请参见下文。

awk不存在这种问题,因为它不是 shell,并且在操作浮点时不必在二进制和十进制表示之间不断地来回转换。

zsh 的方法

zsh与许多其他编程语言(包括yashksh93)以及 shell 中使用的许多处理浮点数的工具(如awk、 ...)一样,对这些数字的二进制表示形式执行算术运算。printf

这既方便又高效,因为这些操作由 C 编译器支持,并且在大多数体系结构上是由处理器本身完成的。

zsh使用doubleC 类型作为实数的内部表示。

在大多数体系结构(以及大多数编译器)上,这些都是使用 IEEE 754 双精度二进制浮点实现的。

这些的实现有点像我们的 1.12e4 工程符号十进制数字,但采用二进制(基数 2)而不是十进制(基数 10)。尾数为 53 位(其中隐含 1 位),指数为 11 位(以及符号位)。这些通常会为您提供比您需要的更高的精度。

当评估像这样的算术表达式1. / 10(这里有一个文字浮点常量作为操作数之一)时,zsh将它们从文本十进制表示形式double内部转换为 s (使用标准strtod()函数)并执行导致新的double.

1/10 可以用十进制表示为 0.1 或 1e-1,但就像我们不能用十进制表示 1/3(基数为 3、6 或 9 就可以)一样,1/10 也不能表示二进制(因为 10 不是 2 的幂)。就像 1/3 是十进制的0.333333 adlib一样,1/10 是二进制的 .0001100110011001100110011001 adlib或 1.10011001100110011001 adlib p-4 (其中p-4代表 2 -4,(此处的 4 为十进制))。

由于我们只能存储这些值的 52 位1001...,因此 1/10 作为 adouble变为 1.1001100110011001100110011001100110011001100110011010p-4 (注意最后 2 位数字的舍入)。

这是我们可以用 s 得到的最接近的 1/10 表示double。如果我们将其转换回十进制,我们会得到:

#         1         2
#12345678901234567890
.1000000000000000055511151231257827021181583404541015625

之前double的(1.1001100110011001100110011001100110011001100110011001p-4 是:

.09999999999999999167332731531132594682276248931884765625

以及(1.1001100110011001100110011001100110011001100110011011p-4)之后的一个:

.10000000000000001942890293094023945741355419158935546875

没有那么接近。

现在,zsh首先是一个 shell,即命令行解释器。迟早需要将算术表达式的结果传递给命令的浮点数。在非 shell 编程语言中,您可以将您传递double给您想要调用的函数。但在 shell 中,你只能通过字符串到命令。您无法传递您的原始字节值,double因为它很可能包含 NUL 字节,并且无论如何命令都不知道如何处理它们。

因此,您需要将其转换回命令可以理解的字符串表示法。有一些符号,例如 C99 0xc.ccccccccccccccdp-7 浮点十六进制符号,可以轻松表示 IEEE 754 二进制浮点数,但它尚未得到广泛支持,而且对于大多数凡人来说通常毫无意义(一开始很少有人会识别 0.1)上面的视线)。所以算术展开的结果$((...))实际上是一个十进制表示的浮点数。

现在 .1000000000000000055511151231257827021181583404541015625 有点长,考虑到doubles (以及算术表达式的结果)没有那么高的精度,给出那么高的精度是没有意义的。实际上, .1000000000000000055511151231257827021181583404541015625、 .100000000000000005551115123125782 甚至在这种情况下 0.1 都会转换回相同的double.

如果我们截断(并四舍五入)到 15 位数字,就像yash(内部也使用doubles 进行浮点运算)那样,我们确实得到 0.1,但是对于其他两个doubles,我们也得到 0.1,所以我们由于我们无法区分这 3 个不同的数字,因此丢失了信息。如果我们截断为 16 位,我们仍然会得到 2 个不同的doubles,结果为 0.1。

我们需要保留 17 位有效十进制数字,以免丢失存储在 IEEE 754 双精度数中的信息。作为双精度维基百科文章指出(引用 IEEE 754 背后的主要架构师 William Kahan 的一篇论文):

如果将 IEEE 754 双精度数字转换为至少有 17 位有效数字的十进制字符串,然后再转换回双精度表示形式,则最终结果必须与原始数字匹配

相反,如果我们使用较少的位,则有一些二进制double值,一旦我们将它们转换回来,我们就不会得到相同的值,double如上例所示。

这就是它的zsh作用,它选择将二进制格式的整个精度保留double为算术扩展结果给出的十进制表示形式,以便再次使用时将其转换为某种东西(例如awkorprintf "%17f"或 zsh 自己的算术表达式...)回到a,double回来还是一样double

如代码所示zsh(2000 年就已经存在,当时添加了浮点支持zsh):

    /*
     * Conversion from a floating point expression without using
     * a variable.  The best bet in this case just seems to be
     * to use the general %g format with something like the maximum
     * double precision.
     */

您还会注意到,它扩展了浮点数,这些浮点数在被截断并.附加时没有小数部分,以确保它们在算术表达式中再次使用时被视为浮点数:

$ zsh -c 'echo $((0.5 * 4))'
2.

如果没有,并且在算术表达式中重用,它将被视为整数而不是浮点数,这会影响正在使用的运算的行为(例如 2/4 是整数除法,产生 0 和 2 ./4 是浮点除法,结果为 0.5)。

现在,有效数字位数的选择意味着,对于 0.1 作为输入的情况,1.10011001100110011001100110011001100110011010p-4 二进制double(最接近 0.1 的一个)变为 0.100000000000001,这对人类来说看起来很糟糕。当误差在另一个方向时,例如 0.3 变成 0.29999999999999999 时,情况会更糟。

还有一个相反的问题,当我们将该数字传递给支持的应用程序时更多的精度比doubles 更高,我们实际上传递了 0.000000000000001 的错误(来自用户输入的值,如 0.1),然后它变得重要:

$ v=$((0.1)) awk 'BEGIN{print ENVIRON["v"] == 0.1}'
1
$ v=$((0.1)) yash -c 'echo "$((v == 0.1))"'
1

好的,因为awkyash使用doubles 就像 一样zsh,但是:

$ echo "$((0.1)) == 0.1" | bc
0
$ v=$((0.1)) ksh93 -c 'echo "$((v == 0.1))"'
0

不好,因为在我的系统上bc使用任意精度和扩展精度。ksh93

现在,如果原始十进制输入不是 0.1 (1/10),而是 0.11111111111111111(或 1/9 的任何其他任意近似值),那么表格就会翻转,表明对浮点数进行相等比较是非常无望的。

人类显示伪影问题可以通过指定精度来解决显示时(在使用完整精度完成所有计算之后),例如使用printf

$ x=$((1./10)); printf '%s %g\n' $x $x
0.10000000000000001 0.1

( ,类似于 中浮点数的默认输出格式的%g缩写)。这也消除了整数浮点数的额外尾随。%.6gawk.

yash(和 ksh93 的)方法

yash选择以牺牲精度为代价来消除伪影,15 位十进制数字是有效十进制数字的最高位数,可以保证在将数字从十进制转换为二进制并再次转换回十进制时不会出现这种伪影,就像我们的$((0.1))案件。

事实上,二进制数中的信息在转换为十进制时会丢失,这可能会导致其他形式的伪影:

$ yash -c 'x=$((1./3)); echo "$((x == 1./3)) $((1./3 == 1./3))"'
0 1

尽管(内)等式比较对于浮点通常是不安全的。在这里,我们可以期望x1./3是相同的,因为它们是完全相同的操作的结果。

还:

$ yash -c  'x=$((0.5 * 3)); y=$((1.25 * 4)); echo "$((x / y))"'
0.3
$ yash -c  'x=$((0.5 * 6)); y=$((1.25 * 4)); echo "$((x / y))"'
0

(由于 yash 并不总是在浮点结果的十进制表示中包含.e,因此下一个算术运算最终可能是整数运算或浮点运算)。

或者:

$ yash -c 'a=$((1e15)); echo $((a*100000))'
1e+20
$ yash -c 'a=$((1e14)); echo $((a*100000))'
-8446744073709551616

$((1e15))扩展1e+15为浮点型,而$((1e14))扩展为 100000000000000 则作为整数并导致溢出,因为我们实际上正在进行整数乘法而不是浮点乘法)。

虽然有多种方法可以通过降低显示精度来解决伪影问题(zsh如上所示),但在其他 shell 中无法恢复精度损失。

$ yash -c 'printf "%.17g\n" $((5./9))'
0.555555555555556

(仍然只有15位数字)

无论如何,无论截断多短,您总是可能最终在算术扩展的结果中得到伪影,因为错误是浮点表示所固有的。

$ yash -c 'echo $((10.1 - 10))'
0.0999999999999996

这是为什么不能真正将等式运算符与浮点一起使用的另一个例子:

$ zsh -c 'echo $((10.1 - 10 == 0.1))'
0
$ yash -c 'echo "$((10.1 - 10 == 0.1))"'
0

克什93

ksh93 的情况更为复杂。

ksh93 使用long doubles 而不是double可用的地方。long doubles 仅由 C 保证至少与doubles 一样大。实际上,根据编译器和体系结构,它们通常是 IEEE 754 双精度(64 位),如doubles、IEEE 754 四倍精度(128 位)或扩展精度(80 位精度,但通常存储在 128 位上) )就像 ksh93 是为在 x86 上运行的 GNU/Linux 系统构建的一样。

为了以十进制完整且明确地表示它们,您分别需要 17、36 或 21 位有效数字。

ksh93 在 18 位有效数字处截断。

目前我只能在 x86 架构上进行测试,但我的理解是,在long doubles 类似于doubles 的系统上,您会得到与 with 相同的结果zsh(更糟糕的是,它使用 18 位数字而不是 17 位数字)。

doubles 具有 80 位或 128 位精度时,您会遇到与 s 相同的问题,yash只是与使用 s 的工具交互时情况会更好,double因为 ksh93 为它们提供了比它们需要的更高的精度,并且会保留与它们一样多的精度给它。

$ ksh93 -c 'x=$((1./3)); echo "$((x == 1. / 3))"'
0

仍然是一个“问题”,但不是:

$ ksh93 -c 'x=$((1./3)) awk "BEGIN{print ENVIRON[\"x\"] == 1/3}"'
1

没问题。

但行为不是最理想的地方是typeset -F<n>/-E<n>使用时。在这种情况下,当为变量赋值时,ksh93 会截断为 15 位有效数字,即使您请求的值<n>大于 15:

$ ksh93 -c 'typeset -F21 x; ((x = y = 1./3)); echo "$((x == y))"'
0
$ ksh93 -c 'typeset -F21 x; ((y = 1./3)); x=$y; echo "$((x == y))"'
0

之间的行为存在差异ksh93zsh并且yash在处理区域设置的十进制基数字符(是否使用/识别 3.14 或 3,14)时,这会影响在算术表达式内重新输入算术扩展结果的能力。 zsh 再次保持一致,因为无论用户的区域设置如何,我们始终可以在算术表达式中使用扩展的结果。

awk

awk是其中之一编程语言这不是一个外壳并处理浮点数。这同样适用于perl...

它的变量不限于字符串,现在通常在内部将数字存储为二进制doublegawk还支持任意精度数字作为扩展)。仅当以下情况时才会转换为字符串十进制表示法印刷像这样的数字:

$ awk 'BEGIN {print 0.1}'
0.1

在这种情况下,它使用特殊变量中指定的格式OFMT%.6g默认情况下),但可以任意大:

$ awk -v OFMT=%.80g 'BEGIN{print 0.1}'
0.1000000000000000055511151231257827021181583404541015625

或者,当存在数字到字符串的隐式转换时,例如使用字符串运算符(如连接、...)时subtr()index()将使用 CONVFMT 变量(整数除外)。

$ awk -v OFMT=%.0e -v CONVFMT=%.17g 'BEGIN{x=0.1; print x, ""x}'
1e-01 0.10000000000000001

或者printf明确使用时。

通常不存在内部精度丢失的问题,因为我们不会在十进制和二进制表示之间来回转换。在输出方面,我们可以决定给出多少精度。

结论

最后,我仅提出我个人的看法。

Shell 浮点运算不是我经常使用的东西。大多数时候,它是通过zshzcalc自动加载计算器功能来打印 6 位精度的浮点数。大多数情况下,小数点后前 3 位数字对于此类用法来说只是噪音。

算术展开式具有高精度是必要的。无论是完全精度还是尽可能高的精度,同时避免一些人为因素,可能并不那么重要,特别是考虑到没有人会使用 shell 来进行大量的浮点计算。

虽然知道在 中zsh,往返十进制不会引入额外的错误,这确实让我感到安慰,但我发现更重要的事实是,扩展的结果可以安全地在算术表达式中使用,浮点数保持浮点数并且,例如,当在小数基数所在的语言环境中使用脚本时,脚本将继续工作。


1 zsh 是我所知道的唯一一个可以以 10 以外的基数进行算术展开的类似 Korn 的 shell,但这仅适用于整数。

答案2

简短的回答是:1/10 不是以 2 为基数的简单分数,它不能用有限数量的以 2 为基数的数字表示。

zsh显然使用内部浮点数据表示来计算浮点表达式和格式转换。

相关内容