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 来看转化过程:
- 负号 S 位为 1, 取绝对值转二进制得:1001.101,(整数除 2 取余,小数乘 2 取整,沿着小数点排列)
- 科学计数法:1.001101 * 2 ^ 3
- 计算指数位: 00 000000011 (指数真值 3) + 011 11111111 (偏移量 1023) = 100 00000010
- 最终存储值: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。
执行以下步骤:
- 令x为 thisNumberValue( this value)。
- ReturnIfAbrupt ( x )。
- 令f为ToInteger ( fractionDigits )。(如果fractionDigits是 undefined,这一步会产生值0)。
- ReturnIfAbrupt ( f )。
- 如果f < 0 或f > 20,则抛出RangeError异常。但是,允许实现扩展f小于 0 或大于 20 的toFixed值的行为。在这种情况下 ,不一定会为此类值抛出RangeError。toFixed
- 如果x是NaN,则返回 String "NaN"。
- 让s成为空字符串。
如果x < 0,则
- 让小号是“ -”。
- 让x = – x。
如果x ≥ 10 21,则
- 让m = ToString ( x )。
否则x < 10 21 ,
- 设n是一个整数,n ÷ 10 f – x的精确数学值尽可能接近于零。如果有两个这样的n,则选择较大的n。
- 如果n = 0,则让m为 String "0"。否则,让m是由n的十进制表示的数字组成的字符串(按顺序,没有前导零)。
如果f ≠0,则
- 令k为m 中的元素数。
如果k ≤ f,则
- 设z是由代码单元 0x0030的f +1– k次出现组成的字符串。
- 让m是字符串z和m的串联。
- 令k = f + 1。
- 设a为m的前k – f 个元素,设b为m的其余 f 个元素。
- 让米是三个字符串的串联一个,"."和b。
- 返回字符串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 编译和调试
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 业界解决方案
精确四舍五入
银行家四舍五入
……