计算器是怎样计算 0.1+0.2 的?

2023年 8月 28日 56.3k 0

计算器是怎样计算 0.1+0.2 的?

浮点数的误差累积

众所周知,现代CPU使用 IEEE 754 中规定的单精度或双精度格式存储浮点数,而大部分编程语言使用这一格式存储非整数,因此诸如 0.1+0.2 不等于 0.3 的现象在部分语言的程序开发中广泛存在。需要特别指出的是,IEEE 754 下不能存储 0.1 和 0.2,当你在程序里为一个浮点数赋0.1或0.2时,其实是:

你输入的. 计算机存储的单精度 双精度
0.1 0.10000000149011611938 0.10000000000000000555
0.2 0.20000000298023223877 0.20000000000000001110
0.3 0.30000001192092895508 0.29999999999999998890
0.1+0.2 0.30000001192092895508 0.30000000000000004441

一些语言下浮点数相加,默认直接输出“0.3”的字样,纯粹是因为编译器或解释器自动按一定的位数进行舍入。另外,有意思的是,0.1+0.2的计算结果在双精度下与0.3的结果不一致,但在单精度下却不存在这个问题。

这里简单列出使用双精度时 0.1+0.2 的计算过程:

IEEE-754 浮点数 说明
_1.1001100110011001100110011001100110011001100110011010 * 2^-4 0.1的近似
_1.1001100110011001100110011001100110011001100110011010 * 2^-3 0.2的近似
调整使指数相同
_0.1100110011001100110011001100110011001100110011001101 * 2^-3 0.1的对齐(移1位)
_1.1001100110011001100110011001100110011001100110011010 * 2^-3 0.2的对齐(移0位)
先相加再截位
10.0110011001100110011001100110011001100110011001100111 * 2^-3 相加后
_1.0011001100110011001100110011001100110011001100110100 * 2^-2 化成浮点数格式看要不要入位*
对比一下
_1.0011001100110011001100110011001100110011001100110011 * 2^-2 0.3的近似

* 这里移出去了一个1,但移出去的1不总是要入位(Rounding)的,具体可见IEEE-754规范的Section 5.1 和 4.1。简单来说,如果高一位已经是0了,就不要入位;如果高一位是1,就入位使高位成为0。总之,就是尽量使最低位成为0。本例中是要入位的。

不过,电脑上的计算器软件似乎并不存在类似于浮点数下 0.1+0.2 的精度误差累积问题——所以它们是怎么做的呢?

XCalc 是怎么做的

XCalc 是 Xorg 项目下配套提供的一个示例程序,实现了基本的计算器界面与功能。从其代码中可以看到XCalc直接使用了双精度浮点数存储计算结果。

static int exponent=0;
static double acc =0.0; // 全局变量
static double dnum=0.0;
#define XCALC_MEMORY 10
static double mem[XCALC_MEMORY] = { 0.0 };
static void   DrawDisplay(void);
switch (lastop) { /* perform the operation */
case kADD: acc += dnum;  break; // 直接对两双精度变量进行运算
case kSUB: acc -= dnum;  break;
case kMUL: acc *= dnum;  break;
case kDIV: acc /= dnum;  break;

然而,在向GUI输出计算结果时:

snprintf(dispstr, sizeof(dispstr), "%.8g", n);`

考虑到精度误差的累积是有限度的,所以XCalc直接把可能出现误差的位给舍入了。相当简单粗暴。

bc 是怎么做的

bc 是 POSIX 标准要求的一个简单的任意精度算术计算程序,大部分Linux发行版都附带了由 GNU 实现的 bc。这里对 bc-1.03 版本的源代码进行分析。bc自己定义了一个叫bc_num的结构体类型用于存储计算中使用的“数值”。显然,这一结构体使用十进制小数的形式存储每一位上的数字。

typedef struct
    {
      sign n_sign;
      int  n_len;	/* The number of digits before the decimal point. */
      int  n_scale;	/* The number of digits after the decimal point. */
      int  n_refs;      /* The number of pointers to this number. */
      char n_value[1];  /* The storage. Not zero char terminated. It is 
      			           allocated with all other fields.  */
    } bc_struct;

typedef bc_struct *bc_num;

bc_num的算术运算同样是逐位进行的。例如加法运算的代码位于_do_add函数中,计算过程大致分为三部分,其中仔细考虑了进位问题。这样的思路很接近大整数计算的实现,只是这里计算小数要额外考虑小数点。

static bc_num
_do_add (n1, n2)
     bc_num n1, n2;
{

不过,由于有限小数不能表示全部有理数,所以使用bc计算1/3+1/3+1/3会给出0.999999...这样的结果。

Windows 计算器是怎么做的

Windows 计算器的UWP版本已经在Github上开源,不过用 Visual Studio 配置UWP的开发环境还是有些麻烦。这里使用的是 tag 2201 版本。

Windows 计算器中的数字是以分数的形式表示的,例如0.1记为分数 1/10,0.2记为分数 2/10。这样的分数表示对应代码中的Rational类。分数就是有理数,所以Windows 计算器可以准确的存储有理数。

    class Rational
    {
    private:
        Number m_p; // 分子
        Number m_q; // 分母
    };
    
    class Number
    {
    private:
        int32_t m_sign;
        int32_t m_exp;
        std::vector m_mantissa; // 存储十进制数字
    };

代码中使用运算符重载定义了Rational的算术运算,其中算法加法就使用分式的通分计算结果。不过,核心函数定义在C结构体PRAT上,Rational更像是对底层PRAT相关函数的 C++ 包装。

    Rational& Rational::operator+=(Rational const& rhs)
    {
        PRAT lhsRat = this->ToPRAT();
        PRAT rhsRat = rhs.ToPRAT();

        try
        {
            addrat(&lhsRat, rhsRat, RATIONAL_PRECISION);
            destroyrat(rhsRat);
        }
        catch (uint32_t error)
        {
            destroyrat(lhsRat);
            destroyrat(rhsRat);
            throw(error);
        }

        *this = Rational{ lhsRat };
        destroyrat(lhsRat);

        return *this;
    }

这个核心函数要实现分数相加了:AB+CD=AD+BCBD\frac{A}{B}+\frac{C}{D} = \frac{AD+BC}{BD}BA​+DC​=BDAD+BC​。如果分母相同直接分子相加即可。

void addrat(_Inout_ PRAT* pa, _In_ PRAT b, int32_t precision)
{
    PNUMBER bot = nullptr;

    if (equnum((*pa)->pq, b->pq))
    {
        // Very special case, q's match.,
        // make sure signs are involved in the calculation
        // we have to do this since the optimization here is only
        // working with the top half of the rationals.
        (*pa)->pp->sign *= (*pa)->pq->sign;
        (*pa)->pq->sign = 1;
        b->pp->sign *= b->pq->sign;
        b->pq->sign = 1;
        addnum(&((*pa)->pp), b->pp, BASEX);
    }
    else
    {
        // Usual case q's aren't the same.
        DUPNUM(bot, (*pa)->pq);
        mulnumx(&bot, b->pq); // bot 存储了两分数分母之积
        mulnumx(&((*pa)->pp), b->pq);
        mulnumx(&((*pa)->pq), b->pp);
        addnum(&((*pa)->pp), (*pa)->pq, BASEX);
        destroynum((*pa)->pq);
        (*pa)->pq = bot;
        trimit(pa, precision);

        // Get rid of negative zeros here.
        (*pa)->pp->sign *= (*pa)->pq->sign;
        (*pa)->pq->sign = 1;
    }
}

无疑,使用分式格式存储操作数是最好的,因为分式可以表示一切有理数。在科学模式下计算1/3+1/3+1/3得到的是1

怎样避开浮点数的精度误差累积

从上面针对三款软件的分析中,我们可以看到,避开浮点数的精度误差累积的方法就是不使用浮点数,可以使用十进制有限小数下逐位记录的方法,也可以使用分子分母均为整数的分式表示法。

不过,我在这里想说一个特殊的场景:怎样存储金额。最经典的是使用定点数的方法,例如 “1.35元人民币”记为整数135。这其实就是分母固定为100时的分数表示法。下次处理金额的时候要记住,可别再用浮点数了。

参考

  • IEEE-754 规范 www.ime.unicamp.br/~biloti/dow…
  • 各项目源码(链接附在文中)
  • 顺便分享一些有意思的网站

  • 0.30000000000000004.com/
  • www.h-schmidt.net/FloatConver…
  • 相关文章

    服务器端口转发,带你了解服务器端口转发
    服务器开放端口,服务器开放端口的步骤
    产品推荐:7月受欢迎AI容器镜像来了,有Qwen系列大模型镜像
    如何使用 WinGet 下载 Microsoft Store 应用
    百度搜索:蓝易云 – 熟悉ubuntu apt-get命令详解
    百度搜索:蓝易云 – 域名解析成功但ping不通解决方案

    发布评论