toFixed 精度问题

1 背景

卖家今天做广告推广,新增曝光率 1000,商品总曝光量达 11500,很高兴,上平台一看,显示 1.1 w 曝光率,很疑惑,按四舍五入计算,也应该是 1.2w 才对吧,怎么回事呢?

通过排查发现是 JS 的 toFixed 的 bug。

2 toFixed 是什么?

来自 MDN 的定义:

toFixed()返回numObj不使用指数表示法并且digits在小数点后精确地具有 数字的字符串表示形式 。 如有必要,数字会四舍五入,并在必要时用零填充小数部分,使其具有指定的长度。如果 的绝对值numObj大于或等于1e+21,则此方法调用 Number.prototype.toString() 并返回指数表示法的字符串。

而在下方有警告:

警告:浮点数不能以二进制精确表示所有小数。这可能会导致意外结果,例如 0.1 + 0.2 === 0.3返回false.

通过 Chrome 浏览器控制台进行测试:

(1.15).toFixed(1)
// "1.1"
(1.25).toFixed(1)
// "1.3"
(1.35).toFixed(1)
// "1.4"
(1.45).toFixed(1)
// "1.4"
(1.55).toFixed(1)
// "1.6"

从上可以看到 toFixed 在部分例子中确实是有问题的。JS 并不区分整数和浮点数,只要 Number 类型,即都是浮点数,采用的是 IEEE 754 标准的 64 位双精度格式。

3 什么是 IEEE 754

来自百度百科的定义:

IEEE二进制浮点数算术标准(IEEE 754)是20世纪80年代以来最广泛使用的浮点数运算标准,为许多CPU与浮点运算器所采用。这个标准定义了表示浮点数的格式(包括负零-0)与反常值(denormal number)),一些特殊数值(无穷(Inf)与非数值(NaN)),以及这些数值的“浮点数运算符”;它也指明了四种数值舍入规则和五种例外状况(包括例外发生的时机与处理方式)。
IEEE 754规定了四种表示浮点数值的方式:单精确度(32位)、双精确度(64位)、延伸单精确度(43比特以上,很少使用)与延伸双精确度(79比特以上,通常以80位实现)。只有32位模式有强制要求,其他都是选择性的。大部分编程语言都有提供IEEE浮点数格式与算术,但有些将其列为非必需的。例如,IEEE 754问世之前就有的C语言,有包括IEEE算术,但不算作强制要求(C语言的float通常是指IEEE单精确度,而double是指双精确度)。

IEEE 浮点数标准是从逻辑上用三元组{S,E,M}来表示一个数 V 的,即 V=(-1)S×M×2^E:

IEEE 754 标准 64 位双精度里的 9.625 值为例:

[ S(符号位)] [ E(指数位) ] [ M(有效数字位) ]
[ 0 ] [ 10000000010 ] [ 0011010000000000000000000000000000000000000000000000]

位置 描述
符号位 s(Sign) 决定数是正数(s=0)还是负数(s=1),而对于数值 0 的符号位解释则作为特殊情况处理;
指数位 E(Exponent) 是 2 的幂(可能是负数),它的作用是对浮点数加权;
有效数字位 M(Significand) 是二进制小数,它的取值范围为 1~2-ε,或者为 0~1-ε。它也被称为尾数位(Mantissa)、系数位(Coefficient),甚至还被称作“小数”;

其中的指数位值 = 真实指数值 + 偏移量值(1023),偏移量 = 2^(k-1) - 1,其中 k 表示指数位位数 11 位;

3.1 指数偏移量

因为指数可以为正数,也可以为负数,为了处理负指数的情况,实际的指数值按要求需要加上一个偏移量(Bias)值作为保存在指数段中的值,

3.2 指数位

规格化:S + (E!=0 && E!=2047) + 1.M
非规格化:S + 000 00000000 + M
无穷大:S + 111 11111111 + 00000000 00000000 00000000 00000000 00000000 00000000 0000
无穷大变种(NAN):S + 111 11111111 + (M!=0)

规格化的情况:即上述的一般情况,因为阶码不能为0也不能为2047,所以指数不能为-1023,也不会为1024,只有这种情况才会有隐含位1。

非规范化情况:此时阶码全为0,指数为-1023,如尾数全为0,则浮点数表示正负0;否则表示那些非常的接近于0.0的数。

3.3 有效尾数位

浮点数的表示方法有很多种,例如 9.625 10^3,又可以表示为 0.9625 10^4、96.25 * 10^2。而 IEEE 浮点数标准按照科学计数法,首位只可能是 1,对此 IEEE 754 省略了这个默认的 1,所以有效尾数有 53 位。

这时候有个问题,尾数 M 省略的 1 一定会存在,以至于浮点数无法表示 0.0,怎么表示?
符号位是 0,指数段全为 0,而小数段也全为 0),这就得到 M=f=0。令人奇怪的是,当符号位为 1,而其他段全为 0 时,就会得到值 -0.0。根据 IEEE 的浮点格式来看,值 +0.0 和 -0.0 在某些方面是不同的。

3.4 举个例子

以 -9.625 来看转化过程:

  1. 负号 S 位为 1, 取绝对值转二进制得:1001.101,(整数除 2 取余,小数乘 2 取整,沿着小数点排列)
  2. 科学计数法:1.001101 * 2 ^ 3
  3. 计算指数位: 00 000000011 (指数真值 3) + 011 11111111 (偏移量 1023) = 100 00000010
  4. 最终存储值:1[00110100 00000000 00000000 00000000 00000000 00000000 0000]

但并不是所有的十进制小数都可以用浮点数表示,以 1.15 为例,转化为二进制位:
1.001001100110011001100110011001100110011001100110011……

以 0011 无限循环下去,但对于计算机而言,存储长度是有限的,因此最终存储值为:
0[0010011001100110011001100110011001100110011001100110],

所以 1.15 实际上1.14999999999999991118215802999,很明显可以看出来,四舍五入结果是 1.1,也就是因为 IEEE 754 浮点算术标准无法用二进制精确表示十进制数,导致四舍五入的结果和预期不符合。

4 JSCore toFixed 源码实现

4.1 ECMAScript 规范

ecmaScript 规范里对于 Number.prototype.toFixed(fractionDigits) 的实现规范

toFixed返回一个包含此 Number 值的字符串,以十进制定点表示法表示,小数点后有fractionDigits位。如果fractionDigits未定义,则假定 为0。

执行以下步骤:

  1. 令x为 thisNumberValue( this value)。
  2. ReturnIfAbrupt ( x )。
  3. 令f为ToInteger ( fractionDigits )。(如果fractionDigits是 undefined,这一步会产生值0)。
  4. ReturnIfAbrupt ( f )。
  5. 如果f < 0 或f > 20,则抛出RangeError异常。但是,允许实现扩展f小于 0 或大于 20 的toFixed值的行为。在这种情况下 ,不一定会为此类值抛出RangeError。toFixed
  6. 如果x是NaN,则返回 String "NaN"。
  7. 让s成为空字符串。
  8. 如果x < 0,则

    1. 让小号是“ -”。
    2. 让x = – x。
  9. 如果x ≥ 10 21,则

    1. 让m = ToString ( x )。
  10. 否则x < 10 21 ,

    1. 设n是一个整数,n ÷ 10 f – x的精确数学值尽可能接近于零。如果有两个这样的n,则选择较大的n。
    2. 如果n = 0,则让m为 String "0"。否则,让m是由n的十进制表示的数字组成的字符串(按顺序,没有前导零)。
    3. 如果f ≠0,则

      1. 令k为m 中的元素数。
      2. 如果k ≤ f,则

        1. 设z是由代码单元 0x0030的f +1– k次出现组成的字符串。
        2. 让m是字符串z和m的串联。
        3. 令k = f + 1。
      3. 设a为m的前k – f 个元素,设b为m的其余 f 个元素。
      4. 让米是三个字符串的串联一个,"."和b。
  11. 返回字符串s和m的串联。

按上述说法,对 (1.15).toFixed(1) 来说,我们找到两个数:

11 / 10 - 1.15 //  -0.04999999999999982
12 / 10 - 1.15 // 0.050000000000000044

可以看到前者结果更接近于 0,所以取 1.1。

如果两者都接近于 0,则取两个中更大的整数,例如对 99.55 来说:

995/10 - 99.55 // -0.04999999999999716
996/10 - 99.55 //  0.04999999999999716

此时应该取 99.6,但当我们在浏览器控制台运行 (99.55).toFixed(1) 时,得到的却是 99.5,难道浏览器没有按照规范实现?

4.2 webkit javascript core toFixed 实现

4.2.1 Webkit 编译和调试

JSCrore/WebKit 设置和调试

4.2.1.1 获取 webkit 源码

# Clone the WebKit repository from GitHub
git clone git://git.webkit.org/WebKit.git WebKit.git

4.2.1.2 构建 webkit

(1)xcode 安装

# Install
$ xcode-select --install
already installed...

# Make sure xcode path is properly set
$ xcode-select -p
/Applications/Xcode.app/Contents/Developer

# Confirm installation
$ xcodebuild -version
Xcode 10.1
Build version 10B61

(2)执行构建 JSC (JavaScriptCore) 的脚本作为调试构建。

# Run the script which builds the WebKit
Tools/Scripts/build-webkit --jsc-only --debug

# jsc-only : JavaScriptCore only
# debug    : With debug symbols

注意:安装 cmake 后 path not found, 将cmake命令添加到环境变量中,打开 home 目录下的 .bash_profile 文件加入下面两句,保存修改即可:

# Add Cmake Root to Path
export CMAKE_ROOT=/Applications/CMake.app/Contents/bin/
export PATH=$CMAKE_ROOT:$PATH

(3)设置 lldb(lldb是一个类似于 gdb 的调试器。我们可以使用lldb来调试jsc)

# Incase of a python error, run the following
$ alias lldb='PATH="/usr/bin:$PATH" lldb'

# Load the file to the  debugger
$ lldb ./WebKitBuild/Debug/bin/jsc
(lldb) target create "./WebKitBuild/Debug/bin/jsc"
Current executable set to './WebKitBuild/Debug/bin/jsc' (x86_64).
(lldb) run
Process 4233 launched: './WebKitBuild/Debug/bin/jsc' (x86_64)
>>> 

lldb 相关命令

x/8gx address #查看内存地址 address

next(n) #单步执行
step(s) #进入函数
continue(c) #将程序运行到结束或者断点处(进入下一断点)
finish #将程序运行到当前函数返回(从函数跳出)
breakpoint(b) 行号/函数名 <条件语句> #设置断点
fr v #查看局部变量信息
print(p) x #输出变量 x 的值

4.2.2 源码分析

4.2.2.1 入口,各种情况的处理

EncodedJSValue JSC_HOST_CALL numberProtoFuncToFixed(JSGlobalObject* globalObject, CallFrame* callFrame)
{
    VM& vm = globalObject->vm();
    auto scope = DECLARE_THROW_SCOPE(vm);

    // x 取值 99.549999999999997
    double x;
    if (!toThisNumber(vm, callFrame->thisValue(), x))
        return throwVMToThisNumberError(globalObject, scope, callFrame->thisValue());

    // decimalPlaces 取值 1
    int decimalPlaces = static_cast(callFrame->argument(0).toInteger(globalObject));
    RETURN_IF_EXCEPTION(scope, { });

    // 特殊处理,略
    if (decimalPlaces < 0 || decimalPlaces > 100)
        return throwVMRangeError(globalObject, scope, "toFixed() argument must be between 0 and 100"_s);

    // x 的特殊处理,略
    if (!(fabs(x) < 1e+21))
        return JSValue::encode(jsString(vm, String::number(x)));

    // NaN or Infinity 的特殊处理
    ASSERT(std::isfinite(x));

    // 进入执行 number=99.549999999999997, decimalPlaces=1
    return JSValue::encode(jsString(vm, String::numberToStringFixedWidth(x, decimalPlaces)));
}

从 numberToStringFixedWidth 方法不断进入,到达 FastFixedDtoa 处理方法

需要注意的是,原数值的整数和小数部分都分别采用了指数表示法,方便后面位运算处理
99.549999999999997 = 7005208482886451 2 -46 = 99 + 38702809297715 2 -46

4.2.2.2 分离整数部分和小数部分

// FastFixedDtoa(v=99.549999999999997, fractional_count=1, buffer=(start_ = "", length_ = 122), length=0x00007ffeefbfd488, decimal_point=0x00007ffeefbfd494)

bool FastFixedDtoa(double v,
                   int fractional_count,
                   BufferReference buffer,
                   int* length,
                   int* decimal_point) {
  const uint32_t kMaxUInt32 = 0xFFFFFFFF;
  // 将 v 表示成 尾数(significand) × 底数(2) ^ 指数(exponent) 
  // 7005208482886451 x 2 ^ -46
  uint64_t significand = Double(v).Significand();
  int exponent = Double(v).Exponent();

  // 省略部分代码

  if (exponent + kDoubleSignificandSize > 64) {
    // ...
  } else if (exponent >= 0) {
    // ...
  } else if (exponent > -kDoubleSignificandSize) {
    // exponent > -53 的情况, 切割数字

    // 整数部分: integrals = 7005208482886451 >> 46 = 99 
    uint64_t integrals = significand >> -exponent;
    // 小数部分(指数表达法的尾数部分): fractionals = 7005208482886451 - 99 << 46  = 38702809297715
    // 指数不变 -46
    // 38702809297715 * (2 ** -46) = 0.5499999999999972
    uint64_t fractionals = significand - (integrals << -exponent);
    if (integrals > kMaxUInt32) {
      FillDigits64(integrals, buffer, length);
    } else {
      // buffer 中放入 "99"
      FillDigits32(static_cast(integrals), buffer, length);
    }
    *decimal_point = *length;
    // 填充小数部分,buffer 为 "995"
    FillFractionals(fractionals, exponent, fractional_count,
                    buffer, length, decimal_point);
  } else if (exponent < -128) {
    // ...
  } else {
    // ...
  }
  TrimZeros(buffer, length, decimal_point);
  buffer[*length] = '\0';
  if ((*length) == 0) {
    // The string is empty and the decimal_point thus has no importance. Mimick
    // Gay's dtoa and and set it to -fractional_count.
    *decimal_point = -fractional_count;
  }
  return true;
}

4.2.2.3 对小数部分进行截取和进位

FillFractionals 用来填充小数部分,取几位,是否进位都在该方法中处理

// FillFractionals(fractionals=38702809297715, exponent=-46, fractional_count=1, buffer=(start_ = "99", length_ = 122), length=0x00007ffeefbfd488, decimal_point=0x00007ffeefbfd494)

/*
小数部分的二进制表示法: fractionals * 2 ^ exponent
38702809297715 * (2 ** -46) = 0.5499999999999972

前提:
  -128 <= exponent <=0。
  0 <= fractionals * 2 ^ exponent < 1 
  buffer 可以保存结果
此函数将舍入结果。在舍入过程中,此函数未生成的数字可能会更新,且小数点变量可能会更新。如果此函数生成数字 99,并且缓冲区已经包含 “199”(因此产生的缓冲区为“19999”),则向上舍入会将缓冲区的内容更改为 “20000”。
*/
static void FillFractionals(uint64_t fractionals, int exponent,
                            int fractional_count, BufferReference buffer,
                            int* length, int* decimal_point) {
  ASSERT(-128 <= exponent && exponent <= 0);
  if (-exponent <= 64) { 
    ASSERT(fractionals >> 56 == 0);
    int point = -exponent; // 46

    // 每次迭代,将小数乘以10,去除整数部分放入 buffer

    for (int i = 0; i < fractional_count; ++i) { // 0->1
      if (fractionals == 0) break;

      // fractionals 乘以 5 而不是乘以 10 ,并调整 point 的位置,这样, fractionals 变量将不会溢出。然后整体相当于乘以 10
      // 不会溢出的验证过程:
      // 循环初始: fractionals < 2 ^ point , point <= 64 且 fractionals < 2 ^ 56
      // 每次迭代后, point-- 。
      // 注意 5 ^ 3 = 125 < 128 = 2 ^ 7。
      // 因此,此循环的三个迭代不会溢出 fractionals (即使在循环体末尾没有减法)。
      // 与此同时 point 将满足 point <= 61,因此 fractionals < 2 ^ point ,并且 fractionals 再乘以 5 将不会溢出((fractionals >> point); // 193514046488575 * 2 ** -45 = 5
      ASSERT(digit <= 9);
      buffer[*length] = static_cast('0' + digit); // '995'
      (*length)++;
      // 去掉整数位
      fractionals -= static_cast(digit) << point; // 193514046488575 - 5 * 2 ** 45 = 17592186044415 
      // 17592186044415 * 2 ** -45 = 0.4999999999999716 
    }
    // 看小数的下一位是否值得让 buffer 中元素进位
    // 通过乘2看是否能 >=1 来判断
    ASSERT(fractionals == 0 || point - 1 >= 0);
    // 本例中 17592186044415 >> 44 = 17592186044415 * 2 ** -44 = 0.9999999999999432 , & 1 = 0
    if ((fractionals != 0) && ((fractionals >> (point - 1)) & 1) == 1) {
      RoundUp(buffer, length, decimal_point);
    }
  } else {  // We need 128 bits.
    // ...
  }
}

这样就得到了 995,即规范描述中的 n,后面插入一个小数点即为最终结果 99.5。

4.2.3 总结

js 引擎并没有按规范中说的,去寻找一个 n ,使其 n / (10 ^ f) 尽可能等于 x ,而是将 x 分为整数和小数部分,并采用指数表示法分别进行计算。

处理小数的时候,让小数点右移。用指数表示法的时候,有个细节考虑了底数直接 10 可能会导致溢出,然后采用了底数 5 ,指数递减 1 的方式。在 f 位计算后,最后再计算下一位,看是否需要进位。

当然,最终结果不符合我们日常的计算,核心还是在于 IEEE 754 表示法中,99.55 在调试初期取值就是 99.549999999999997。

5 拓展

5.1 JS 能表示的最大最小值

数的范围有两个概念,最大正数和最小负数,最小正数和最大负数。

从 S、E、M 三个维度看,S 表示正负,E 为指数表示大小,M 有效数字位表示精度。

上面我们说到规格化下:
E 最大值为 111 11111110 - 011 11111111(偏移量) = 011 11111111 = 1023,得到指数值的范围为 [ -2^1023,2^1023 ],即 [ -8.98846567431158e+307, 8.98846567431158e+307 ];
M 有效数字位的最大值是 11111111 11111111 11111111 11111111 11111111 11111111 1111,加上默认的整数 1,尾数值无限接近 2;
综上可得最大正数无限接近于 2 * (8.98846567431158e+307) = 1.797693134862316e+307,最小正数无限接近于 -1.797693134862316e+307;
再看下 JS 定义的最大值 Number.MAX_VALUE = 1.7976931348623157e+308,和我们计算出来的最大正数挺接近的;
所以数字的范围是 [ -1.7976931348623157e+308, 1.7976931348623157e+308],超过这个范围,在 JS 中会显示为 Infinity 或 -Infinity。

接下来看最小正数和最大负数,上面提到在非规格化下,指数位值为 0 且有效数字位值不为 0 时,表示无限接近于 0 的数;
此时 E 值为 = 000 00000001 - 011 11111111(偏移量)+ 1 = -100 00000000(减 1 取反)= -1022,得到指数值的最小值为 2^-1022 = 2.2250738585072014e-308;
而有效数字位值可取的非 0 最小值为 0.00000000 00000000 00000000 00000000 00000000 00000000 0001 = 2^-52
可以得到最小正数值为 2^-1022 * 2^-52 = 2^-1074 = 5e-324;
而 JS 的 Number.MIN_VALUE = 5e-324;正好和我们计算出来的一致;

5.3 业界解决方案

精确四舍五入
银行家四舍五入
……

你可能感兴趣的