计算器是怎样计算 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时的分数表示法。下次处理金额的时候要记住,可别再用浮点数了。