二进制浮点数和.NET
当在.NET中碰到一些运算结果“出错”的情况时,很多人都会感到惊讶。但这并不是.NET所特有的——大多数语言/平台都使用所谓的 "浮点 "算术来表示非整数。这本身是没有问题的,但是你需要对其表面下的本质有所了解,才不至于对一些结果产生疑惑。
需要说明的是,我不是这方面的专家。自撰写本文以来,我发现了另一篇——由真正的专家Jeffrey Sax撰写的文章。我强烈推荐各位也去看看他在浮点数方面的文章。
什么是浮点数?
计算机总会需要某种方式来表示数据,最终这些表示方式总会归结为二进制(0和1)。整型数其实很容易表示(对负数会有适当的约定,并且有明确的范围,能够知道开始表示的范围有多大)但是非整型数就有点难办了。无论你想出什么办法,总会出现一些问题。比如说,拿我们正常写数字的十进制来说:它(本身)无法表示三分之一。末尾会反复出现3。无论你想出什么办法,你总会在一些数字上遇到同样的问题——尤其是无理数(无法用分数表示的数)比如数学常量pi和e,它们总会带来问题。
你可以用两个整数准确地存储所有的有理数,可以通过第一个数除以第二个数表示——但是即使是很"简单"的操作,整数也会迅速地增长,而且像平方根这样的操作很容易产生无理数。还有其他各种方案都会带来问题,但大多数系统会以某种形式使用浮点数。这个想法大致来说就是,通过一个带有比例的整数(尾数mantissa)和另一个表示比例的数(指数exponent)(用来指出“小数点在哪”),来表示一个数。(个人理解:应该就是科学计数法)例如,34.5在"十进制浮点数"中可以表示为:尾数为3.45,指数为1,而3450可以与之有相同的尾数,但是指数是3(34.5就是3.45x101,3450就是3.45x103)。这个例子为了简化使用的是十进制,但最常见的浮点格式是二进制。例如,二进制尾数是1.1,指数是-1,就表示十进制的0.75(二进制1.1==十进制1.5,指数为-1表示“除以2”,就像十进制指数-1表示“除以10”)。
有一点很重要,你需要搞清楚,就像你无法在十进制中(有限)精准的表示三分之一一样,有很多数字在十进制中看起来很简单,但在二进制中却会很长或者是无限的。这意味着(例如)一个二进制浮点变量无法精准表示十进制的0.1。假设你有这样一段代码:
1 | double x = 0.1d; |
变量x实际上存储的是最接近0.1d的可用double类型数。一旦你能理清这一点,就会明白为什么有些计算似乎是 “错误的”。如果你需要计算三分之一加三分之一,但只能用小数点后三位表示三分之一,那你就会得到“错误”的答案:最接近三分之一的是0.333,两者相加是0.666,而不是0.667(更接近三分之二的准确值)。一个二进制浮点数的例子是3.65d+0.05d !=3.7d (虽然在某些情况下可能显示为3.7)。
.NET中的浮点类型有哪些?
C#标准只把double和float列为可用的浮点数(System.Double和System.Single的C#简写),但decimal类型(System.Decimal的简写)其实也是一种浮点数——只不过它是十进制浮点数,指数的范围很有意思。decimal类型在另一篇文章(原文 译文)中已经介绍过了,所以这篇文章就不多说了,我们会集中于double和float类型。这两种类型都是二进制浮点类型,符合IEEE 754标准(一个定义各种浮点类型的标准)。float类型是32位类型(1位符号位,23位尾数和8位指数),double类型是64位类型(1位符号位,53位尾数和11位指数)。
结果并非所望,岂不是很糟糕?
嗯,这要看具体情况。如果你正在编写一个财务相关应用,你可能有着非常严格的错误处理方式,而且金额也要直观地表示为十进制——这种情况下,decimal类型要比float和double更合适。然而,如果你正在编写一个科学类应用,那么与十进制表示法的联系可能就会比较弱,而且可能一开始你处理的数据就没有那么精准(一块钱就是一块钱,但如果你测量出的长度是一米,那可能一开始就会存在误差)。
浮点数的比较
总体概括得出的结论就是,你应该非常非常少地去直接比较浮点数是否相等。通常用大于等于或小于等于来比较是可行的,但当你需要判断相等时,你就该想一想你实际想要的是否几乎相等:一个数字是否与另一个数字几乎相等。一个简单的方法就是用一个数减去另一个,再用Math.Abs算出差值的绝对值,然后判断这个差值是否低于一定的误差。(个人理解:所谓“几乎相等”即判断两个浮点数之间的误差在某个值以内时就认为两者相等)
不过也有一些情况特别病态,这都是JIT优化造成的。请看下面的代码:
1 | using System; |
应该总是输出True对吧?不幸的是,错啦!当在调试(debug)模式下运行时,JIT无法像正常情况下那样进行大量优化,就会显示True。当正常运行时,JIT能够存储的数值远比float能表示的数值更精准,它可以使用x86 80位表示法来表示求和的值、返回值和本地变量。更多细节请参见ECMA CLI规范,第1部分,12.1.3节。将上面代码的注释去除,可能会是JIT的行为保守一些,从而导致结果为True。然而,这取决于具体的实现、CLR版本、处理器等–这都不是你应该依赖的东西。(事实上,在某些环境下,只有部分被注释的行会对结果造成影响。)这也是另一个避免相等判断的原因,哪怕你真的确信结果是相等的。
.NET如何格式化浮点数?
在.NET中,没有内置的方法可以查看浮点数的精确小数,但是你仍然可以通过一些手段做到。(参见本文底部的一些代码。)默认情况下,.NET将double类型格式化为小数点后15位,float类型格式化为小数点后7位(某些情况下,会使用科学计数法;更多信息请参见MSDN关于标准数字格式化字符串的页面)。如果你使用往返格式指定符(round-trip format specifier)(“r”),会将数字格式化为最短的形式,当再解析(到同一类型)时,将会返回原始的数字。如果你是以字符串的形式存储浮点数,而且精确的数值对你很重要,你一定要使用往返指定符,否则很可能会丢失数据。
个人理解:举个例子
1 | class Program |
浮点数在内存中到底是什么样的?
就像上面说的,一个浮点数基本上有一个符号位、一个指数和一个尾数。这三者都是整数,它们的组合可以明确地表示出一个数字。浮点数有各种类别:normalised、subnormal、infinity和not a number(NaN)。大多数数字都是归一化的(normalised),也就是二进制尾数的第一位都被假定为1,这就意味着实际上并不用存它。例如二进制数1.01101可以表示为.01101——第一位的1假定是存在的,如果首位是0(即0.01101),那么就会使用不同的指数去表示。这种技术只有当你使用的指数在适当范围内时才会有效。不在该范围内的数(非常非常小的数)称为subnormal,不假定前导位。"not a number"(NaN)"值是指像0除以0这样的结果。NaN有各种不同的类别,也有一些奇怪的行为。subnormal数有时也被称为denormalised数。(个人理解:这部分的概念:normalised和subnormal可以翻译为规格化数和非规格化数,但怕造成歧义就保留了原文,建议看看文章开头链接里的那篇文章,这几个概念介绍的很详细)
符号位、指数和尾数在字符位级别的实际表示方法是,每一个符号位、指数和尾数都是一个无符号整数,按照符号位、指数和尾数的顺序存储。"真实 "的指数是有偏差的——例如,在double的情况下,指数偏移了1023位,所以存储的指数如果是1026,实际计算时就代表3。下表以double为例,说明了符号位、指数和尾数各组合的含义。同样的原理也适用于float,只是数值略有不同(如偏移量)。请注意指数值在表中是"存储指数位"这一列,是偏移之前的值(这就是为啥偏移量1023显示在了"数值"这一栏)。
| 符号位(s,1位) | 存储指数位(e,11位) | 尾数(m,52位) | 数值类型 | 数值 |
|---|---|---|---|---|
| 任意数 | 非零数 | 任意数 | Normal | (-1)5 x 1.m(二进制表示) x 2e-1023 |
| 0 | 0 | 0 | Zero | +0 |
| 1 | 0 | 0 | Zero | +0 |
| 0 | 2047 | 0 | Infinity | 正无穷(Positive infinity) |
| 1 | 2047 | 0 | Infinity | 负无穷(Negative infinity) |
| 0 | 2047 | 非零数 | NaN | n/a |
实例
考虑以下64位二进制数:
0 10000000100 0111001101101101001001001000010101110011000100100011
作为一个double类型数,将其拆分:
- 符号位:0
- 指数:10000000100(二进制)== 1028(十进制)
- 尾数:0111001101101101001001001000010101110011000100100011
因此,这是一个正常(normal,对应表中第一行类型)的数值
(-1)0 x 1.0111001101101101001001001000010101110011000100100011 (二进制) x 21028-1023
可以简单的表示为
1.0111001101101101001001001000010101110011000100100011 (二进制) x 25
或者
101110.01101101101001001001000010101110011000100100011
十进制表示就是:46.42829231507700882275457843206822872161865234375,但在.NET中会默认显示为46.428292315077或者使用往返格式指定符(round-trip format specifier)格式化后显示为46.428292315077009。
示例代码
DoubleConverter.cs:这是一个相当简单的类,它可以将一个double类型转化为精确的十进制字符串。请注意,虽然十进制的有限小数不一定有有限的二进制表示,但是所有有限的二进制小数都可以通过有限的十进制小数表示(因为2是10的倍数)。该类的使用非常简单——只需调用DoubleConverter.ToExactString(value),就会返回value的精确字符串表示。
NaNs
NaNs是个奇怪的怪物。NaNs有两种类型——signalling和quiet,简称SNaN和QNaN。从位模式上看,QNaN会设置尾数的顶位,而SNaN却会清除顶位。QNaN用来表示一个数学运算结果是不确定的,而SNaN用来表示异常(运算无效,不仅仅是一个不确定的结果)。
关于NaNs,大多数人感到奇怪的是,它自身不相等。例如,Double.NaN == Double.NaN返回的是false。相反,你需要使用Double.IsNaN来判断一个值是否不是一个数字。幸好,大多人除了在这样的文章,很少会再碰到NaNs。
总结
只要你了解了二进制浮点数的原理,使用它就完全没问题,不要指望你程序中的值完全是十进制的,也不要指望涉及二进制浮点数的计算一定能得到精确的结果。即使两个数字都以你使用的正确类型被表示了出来,涉及两数的运算结果也不一定能正确表示。这种情况在除法中最容易出现(例如,尽管1和10都是可以被确切表示的,但1/10却无法被确切表示),这种情况可能发生在任何操作中——即使是看似无害的操作,如加法和减法。
如果你特别想要精确的十进制数,可以考虑使用decimal类型来代替——但预计这样做会有性能问题。(其实可以很快地设计出一个测试方法,从而得出double类型的乘法运算速度比decimal类型的小数乘法运算速度快了40倍,不要太在意具体的数值,但这已经足够说明,在目前的硬件上,二进制浮点数运算一般是比十进制浮点数运算更快的。)
根据我的经验,大多数商务类型的应用中的数值,用十进制浮点数表示比用二进制浮点数表示更好。尤其是,几乎所有与钱有关的东西,用decimal来表示可能更合适。
本文是对Binary floating point and .NET这篇文章的翻译,作者是著有C# in Depth的大神Jon Skeet。非文章原文或本人对某段文字理解,会以斜体 个人理解:xxx 进行标注。本人翻译能力有限,强烈建议大家去看原版!