浮点数的二进制表示是精确的,比如 0.3 的最接近二进制表示是 00111110100110011001100110011010。
因此,“打印浮点数”这个问题实际上是:寻找一个最短的十进制数,使得它的最接近二进制表示和给定的二进制相同。
目前这方面最好的算法是 Ryu,可参考这里:https://github.com/ulfjack/ryu
系统存储和输出浮点数时,是没有“精确值“的概念的。它的确会按照IEEE754的方式“趋近”。下文详细讲讲。
这里从Java开始讲解(但其实这是个普遍的问题,不限于Java)。Java中println(一个浮点数)实际使用的是Float.toSring(float f)。其文档如下。我把关于位数的说明用红框标注。
英文有点绕,怎么理解呢?我画了个图说明下这个问题。(留意下下这个图比例上并不精确,大致说明一下概念而已)
图中数轴下面表示精确的数学上的值。上面则是float的表示,包括就是IEEE754的Hex表示,和程序对这个float的输出数值。如果你还不理解IEEE754是啥,你可以大致理解为,一个浮点数表示为:
其中mantissa表示有效数字,exponent是2的幂,sign是表示正负数的符号。对于数字精度,最关键的是mantissa。
每个float的最小精度的变动是其mantissa部分加1或者减1。因为mantissa长度有限(float为23个bit),每个float实际上表示的是数学上一个范围(如上图)。在这个范围内,无法再精细的分辨。比如,没法精确的区分0.79999995和0.79999996——他们的IEEE754 Hex表示都是0x3f4ccccc。对mantissa加1或者减1都会跳到隔壁的“范围”里。
每个范围都会有一个对应的数值,就是通过上面的公式计算的结果。比如,0x3f4ccccc的值是“1.5999999046325684 * 2^(-1) = 0.7999999523162841796875“。这个值可以唯一的确定一个“范围”。值得注意的是,对于每一个浮点数,可以找到一个最小的位数n,使得这个值round到n后,依然可以和隔壁的范围不冲突。这也就是上面文档中
... but only as many, more digits as are needed to uniquely distinguish the argument value from adjacent values of typefloat
...
要表达的意思。
例如,对于0x3f4ccccc,这个round的数值就是0.79999995。如果再少保留一位,把末尾的5干掉,四舍五入后就是0.8了,这就跟0x3f4ccccd一样,分不开了。
上图中给出了3个float的“round值“。对于0x3f4ccccc,值就是“0.79999995”;下一个是0x3f4ccccd,这个值就是“0.8”,再下一个是0x3f4cccce,值是“0.8000001”。
当你在程序中输入了一个浮点数X,再输出得到Y,就会经历以下过程。1)程序编译器/解释器会按照IEEE754的方式存储那个数字;2)当你输出时,程序就会按照IEEE754计算值的公式算出对应的“值”,并且round到足够能和相邻浮点数分辨的位数,得到一个字符串表示,也就是Y。
比如你写"float a = 0.8f",程序解析为“用float格式存储0.8,保存到变量a的内存地址上“,他的IEEE754 Hex表示是0x3f4ccccd,其值就是0.800000011920928955078125,最小能分辨的值就是0.8。请留意:虽然你代码写的是0.8,输出的也是0.8,但是这俩0.8不是一回事。比如,如果你输入的是0.799999999,得到的IEEE754表示依然是0x3f4ccccd,输出的还是0.8。
这个“可以最小分辨的值的字符串表示”就是Float.toString(float f)返回的结果,也就是我们常规看到的printf(float f)打印到控制台的结果。
顺便说说另外一个例子:
对于0.8f + 0.1f,其IEEE754的Hex表示为“0x3f666667“, 最小可分辨值是"0.90000004"。
对于0.9f,其IEEE754的Hex表示为“0x3f666666“,最小可分辨值刚好为"0.9"。
所以你可以留意到println(0.8f + 0.1f)与println(0.9f)的输出会不一样。
so,那个该死的把浮点数输出的算法到底是什么?这个问题比表面上看起来要困难得多。可以参考这个系列文章:
或者直接从这篇论文开始: