代码改变世界

.NET 中的二进制浮点类型(译文)

2010-12-24 12:22  DanielWise  阅读(3580)  评论(9编辑  收藏  举报

大多数人会对他们在.NET中的算术的"出错"首先感到惊讶。使用一些称为”浮点”算术来表示非整型数字不是.NET 相比其他大多数语言/平台特殊的地方。在.NET 内部是没问题的,但是你需要知道一些底层正在发生什么,否则你将会对一些结果感到惊讶。

我在这个事情上不是一个专家这不重要。虽然写了这篇文章,我也发现了另外一篇 - 这次是一个真正的专家写的,杰弗里 萨克斯(Jeffrey Sax)。我强烈建议你也同时读他的浮点文章

什么是浮点数?

计算机总是需要一些表示数据的方式,最终这些表示数据的方式总是归结为二进制(0,1组合)。整数很容易表示(对负数有合适的转换,有确定好的范围可以知道表示从多大开始)但是非整数有一些复杂。不管你想出什么方法,总是有一个问题。例如,使用我们自己的十进制方式写数字: 仍然(在十进制内部)不能表达三分之一,你只是在一个3循环中结束。无论你使用多少进制,一些数字都会产生同样的问题 - 特别的,“无理数”的数字(那些不能用以分数表示的数字)如常量PI(音: pai)和e(指数e)总是有一些问题。

你可以将所有有理数用精确的两个整型数表示,第一个数被第二个数除的结果 - 但是即便是一个非常”简单”的操作整数都可以增长的非常大且非常快,平方根操作也会趋向产生无理数。有很多其他的因素会导致导致,但是最常用的解决问题的方式就是使用一种格式或其他格式的浮点类型。思想就是基础有可以用来扩展表达的一些数字(尾数),另外(指数)用来表示规模是多大,以“小数点要去哪里”的形式表示。例如,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除”,二进制1.1==20.2-1==1.5(译者注)).

理解在同样的方式你不能通过一个十进制扩充(无限)来精确表达三分之一是很重要的,有很多数字在十进制形式看起来很简单,但是在二进制表示中却有长的或者无限的扩展。这意味着(举例)一个二进制浮点变量不能有精确的十进制值0.1。相反,假设你又一些如下代码:

double x = 0.1d;

变量x实际上将存储最接近那个值的double型值。一旦你脑子里可以转过弯儿,那么为什么一起计算结果看起来是”错误”的将会变得很明显。如果你被要求计算1/3 + 1/3,这两个数相加的结果是0.666,而不是0.667(更接近两个1/3 的和)。一个二进制浮点类型的表达式是3.65d+0.05d != 3.7d(尽管在一些情况下它显示成3.7)。

.NET 中的浮点类型是什么样子的?

C#标准仅列出double和float作为可用的浮点类型(这些是C#中System.Double和System.Single的速记表示),但是decimal类型(速记表示为System.Decimal)实际上也是一个浮点类型 - 它仅是十进制浮点类型,但是指数的范围很有趣。decimal类型在另外一篇文章中描述,所以这篇文章不会做任何深入探讨 - 我们关注double和float.这两个都是二进制浮点类型,参照IEEE 754(一个多种浮点类型的标准定义)。float是一个32位类型(1个符号位, 23位的尾数和8位指数), double是一个64位类型(1个符号位, 52位尾数和11位指数)。

结果不是我期望的是不好的结果吗?

好吧,那取决于情况。如果你在写财务软件,你可能要非常严格的定义处理错误的方式,数量也是直觉上用10进制表示 - 在这种情况decimal类型更加与float或者double类型相似。如果,然而,如果你在写一个科学应用程序,使用十进制浮点表示法可能会有一点弱,你也可能想要开始处理一些低精度的数目(一美元就是一美元,但是如果你在测量一个单位是米的长度,你可能开始有一些不精确。)

比较浮点数字

所有这些可以得出一个推论,你应该非常,非常少的去直接比较浮点数间是否相等。通常比较大于或者小于会好些,但是当你对相等感兴趣时你应该总是考虑是否你实际上想要的接近相等:一个数字总是与另外一个相同。做这个的一个简单的方式是用一个数减去另外一个数,使用Math.Abs来找到绝对值的不同,然后检查是否这个误差是否低到可以忍受的级别。

也有一些情况是病理的,这些是由于JIT优化导致。查看下面的代码:

using System;
class Test
{
    static float f;
    static void Main(string[] args)
    {
        f = Sum (0.1f, 0.2f);
        float g = Sum (0.1f, 0.2f);
        Console.WriteLine (f==g);
       //g = g + 1;
    }
    static float Sum (float f1, float f2)
    {
        return f1+f2;
    }
}
它应该总是打印True, 对不?错,很不幸。当在debug模式下运行时,JIT不能像正常那样做一些
优化处理,它将打印True.当正常运行时JIT可以将sum 的结果存储的比一个float可以实际表示
的数更加精确 - 它可以使用默认x86 80位表示,例如,对sum 本身,返回值和本地变量。
查看ECMA CLI 规范,第一部分, 12.1.3 章节来获得更多细节。取消上面的注释,让JIT的
行为稍微谨慎一些 - 结果将会是True - 尽管在当前的实现可以让结果是True,但是不应该被
信赖.(在上面语句中将g强制转换成float也可以有同样的效果,尽管它看起来像一个
空操作(no-op).)
这是另外的避免对浮点数做相等比较的原因,尽管你非常确定结果应该是一样的。
(译者注: .NET 平台的运行结果总是True. Java 平台没有自己做过测试,别人的测试也是True)

.NET 是如何格式化浮点数的?

在.NET中没有查看一个浮点数的精确十进制值的内建方式,尽管你可以通过一些工作来完成。(查看这篇文章的末尾的一些可以实现这个功能的代码。)默认情况下,.NET将一个double类型数格式化成15个十进制位置,将一个float类型数格式化成7个十进制位置。(在一些情况将使用科学计数法;查看MSDN标准数字格式字符串页来获得更多内容。)如果你使用往返模式规范(“r”),它会将数字格式化成最短格式,当截取(成同样类型)时,将会变成初始数字。如果你以字符串存储浮点数字而且精确的值对你来说很重要,你应该定义使用往返模式规范,否则你非常可能丢失数据。

一个浮点数在内存中看起来究竟是什么样子的?

正如上面所说的,一个浮点数基本有一个符号位,一个指数和一个尾数。所有这些都是整数,它们三个的联合精确的确定数字的表示形式。有很多浮点数类别: 规范数,低于正常数,无穷数和非数字(NaN, not a number).大多数数字是规范化的,意味着二进制尾数位的第一位是1,也意味着你实际上不需要存储它。例如,二进制数1.01101可以仅用.01101表示 - 开始的1是假设的,如果是0将会使用一个不同的指数。那个技术只有当数字在可以选择适合的指数范围时才可以工作。不在那个范围中的数字(非常,非常小的数字)被称为非正常数字,并假设没有开始位。”不是一个数字”(NaN, not a number)是像指0/0的结果之类的,等等。NaN有很多不同的类别,也有一些老的行为。非正常数字有时候也称作非规范数。

符号位,指数和尾数在比特级别的表示方法都是一个无符号整数,存储的值按顺序先是符号位,然后是指数位,最后是尾数。”真实的”指数是有偏移值的 - 例如,一个double型数,指数是1023偏移,所以当你回来计算出实际值时,一个存储指数值为1026的值就变成3。下面的表显示了符号位,指数和尾数的每种组合的意思,使用double作为一个例子。相同的原则也适用于float,仅有一些不同值(比如偏移值不同)。注意这里给出的指数值是指存储的指数,在偏移值应用之前。(那就是为什么偏移值显示在”值”列。)

符号位(s, 1位)

存储的指数(e, 11位)

尾数(m, 52位)

数字类型

任意 非零 任意 正常 (-1)s x 1.m (二进制) x 2e-1023
0 0 0 0 +0
1 0 0 0 +0
0 2047 0 无穷大 正无穷大
1 2047 0 无穷大 负无穷大
0 2047 非零 非数字 n/a

可以工作的例子

考虑下面的64位二进制数:

0100000001000111001101101101001001001000010101110011000100100011

作为一个double型数,可以被拆分成:

  符号位: 0

  指数位: 10000000100 二进制=1028 十进制

  尾数位: 0111001101101101001001001000010101110011000100100011

这是因此一个正常数的值

(-1)0 x 10111001101101101001001001000010101110011000100100011 (binary) x 21028-1023

也可以更简单的表示为

1.0111001101101101001001001000010101110011000100100011 (binary) x 25

或者

101110.01101101101001001001000010101110011000100100011

在十进制,这是46.42829231507700882275457843206822872161865234375,但是.NET 将会默认显示46.428292315077 或者使用”往返”格式规范表示为46.428292315077009.

示例代码

DoubleConverter.cs : 这是一个相当简单的允许你将一个double类型数转换成它的以字符串表示的精确十进制数的类。注意尽管无穷大十进制数不总是有无穷大二进制表示方法,所有的无穷大二进制数有一个无穷大十进制表示(因为2是10的因数,本质上)。这个类使用起来超级简单 = 仅仅调用DoubleConverter.ToExactString(value)然后value的精确字符串表示形式就会返回。

NaNs

NaNs 是奇兽。有两种类型的NaNs - 信号和安静(signalling and quiet, 译意可能不准确)或者简短表示为SNan和QNaN。在位模式概念中,一个安静的NaN有高位尾数, 而一个信号NaN将它清除了。安静NaNs用来标记精确操作是未定义的,而信号NaNs用来定义其他的(操作是非法的,而不是仅有一个不确定输出)。

大多数人想知道的最奇怪的事情时NaNs不等于它们自己。例如,Double.NaN==Double.NaN 结果是false.相反,你需要使用Double.NaNs来检查是否一个值不是一个数字。幸运的是,大多数人不可能遇到NaNs除了在这篇文章里。

结论

只要你知道发生了什么并且不期望你在你的程序中输入的十进制数就是十进制数值,并且不期望设计二进制浮点数的计算必须生成精确结果,那么二进制浮点算术是很好的。尽管两个数字都被你正在使用的类型精确表示,涉及这两个数的操作结果将不会必须精确表示。这个可以很简单的通过除法操作(例如1/10 不是精确表示的,但1 和10都是精确表示的)看出来但是它可以在任何操作中发生 - 尽管看起来不可能发生的如加法和减法操作。

如果你特别需要精确十进制数字,考虑使用decimal类型来代替 - 但是这样做要考虑到付出性能的代价。(一个非常快设计的测试显示doubles类型数的乘法比decimals类型的乘法快40倍;不要为这个情况花费额外的注意,但是要将在当前硬件环境里二进制浮点运算比十进制浮点运算快很多作为一个提示看待。)

以我的经验来看,大多数商业应用可能有很多种类的用十进制浮点数比二进制浮点更好的值。特别的,几乎任何要与钱相关的数字都更适合使用decimal表示。

(Printable version)

原文链接: http://csharpindepth.com/Articles/General/FloatingPoint.aspx