# 数值修约
# 简介
Math.round 很多中文文档说是取四舍五入的整数。但这个说法是不正确的。我们分别看一下 Python 和 JS 里的 round 的函数返回:
# Python
print(round(20.5)) # 20
print(round(20.51)) # 21
print(round(-20.5)) # -20
print(round(-20.51)) # -21
# JS
console.log(Math.round(20.5)) // 21
console.log(Math.round(20.51)) // 21
console.log(Math.round(-20.5)) // -20
console.log(Math.round(-20.51)) // -21
是不是感觉三观被颠覆?两个语言不一样,我们可以理解各自定义就不一样。但是为什么同样是 JS 里,round(20.51) = 21
,round(-20.51) = -21
, round(-20.5)
又是 -20
呢?
我们不说 Python,只说 JS 里的定义,我们去翻 tc39的标准 (opens new window) :
Returns the Number value that is closest to x and is integral. If two integral Numbers are equally close to x, then the result is the Number value that is closer to +∞. If x is already integral, the result is x. When the Math.round method is called with argument x, the following steps are taken:
- Let n be ? ToNumber(x).
- If n is NaN, +∞𝔽, -∞𝔽, or an integral Number, return n.
- If n < 0.5𝔽 and n > +0𝔽, return +0𝔽.
- If n < +0𝔽 and n ≥ -0.5𝔽, return -0𝔽.
- Return the integral Number closest to n, preferring the Number closer to +∞ in the case of a tie.
我翻译一下:返回最接近Math.round
方法传入一个参数
- 将
隐式类型转化为数字 - 如果
为 NaN / Infinity / -Infinity, 或者是整数,返回 - 如果
,返回 +0 - 如果
, 返回 -0 - 返回最接近 n 的整数,如果和两个整数距离相等,返回更接近
的那个
综上,所以在自然数下,Math.round 的结果与我们理解的四舍五入一致,但在负数上要小心。 关于为什么会有 +0 和 -0,又是要讲一堆,可以参看月影大大的这篇文章 (opens new window)。另外,还有 BigInt 里的 0n,但没有 -0n,这三个 0 在逻辑判断的时候都是 false。
# 关于四舍五入
我们学校所学习的四舍五入,其实并不是 IEEE754 所使用的标准修约方式。这使得我们在计算一些值的时候有惊喜。IEEE754 使用的修约标准叫 Round half to even (opens new window),也称为高斯舍入法、银行家舍入法或四舍六入五成双法。这比四舍五入在累计误差时会更小。
因为四舍五入,舍入的数为0时,舍后就是这个数本身,而1-9共9个数,5处于中间,如果5-9都进一,进一的概率是九分之五,而1-4舍去,概率是九分之四,在累加时会使整体误差偏大。
四舍五入只在中文圈用得多,甚至被老外称为 Chinese Rounding。西方更多用 Bankers Rounding。
银行家舍入法的具体算法:
四舍六入五考虑,五后非零就进一,
五后为零看奇偶,五前为偶应舍去,五前为奇要进一
以下小数舍入两位结果:
0.466 -> 0.47
0.46507 -> 0.46
0.455 -> 0.46
# 事情并没有这么简单
然而,你在实际测试的时候,发现 chrome 下完全不是这么一回事,掀桌:
0.125.toFixed(2) -> 0.13, Python3 是 0.12
0.465.toFixed(2) -> 0.47
10.465.toFixed(2) -> 10.46
只好又去查文档,发现 IEEE745 不光提供了 Round half to even 的方式,还提供了ties away from zero (opens new window) 的修约规则。再去查 tc39 里 toFixed 的实现 (opens new window),toFixed 的实现如下,直接翻译:
NOTE 1
toFixed返回包含此数值(https://tc39.es/ecma262/#number-value)的字符串,该数值用小数点后的小数定点表示法表示。如果分数位数无定义,则假设为0。具体来说,执行以下步骤:
...1-6 步略,主要隐式类型转换、各种异常值和越界判断。
7. 设 x 为实数
8. 令 s 为空字符串 ""
9. 如果 x < 0, 则
a. 令 s 为 "-".
b. 令 x = –x.
10. 如果 x ≥ 10**21, 则
a. 令 m = ToString(x) (科学计数法数字)
11. 否则 (x < 10**21)
a. 令 n 为一个整数,让 n ÷ 10**f – x 准确的数学值尽可能接近零。如果有两个这样 n 值,选择较大的 n。
b. 如果 n = 0, 令 m 为字符串 "0". 否则 , 令 m 为由 n 的十进制表示里的数组成的字符串(为了没有前导零)。
c. 如果 f ≠ 0, 则
i. 令 k 为 m 里的字符数目 .
ii. 如果 k ≤ f, 则
1. 令 z 为 f+1–k 个 ‘0’ 组成的字符串 .
2. 令 m 为 串联字符串 z 的 m 的结果 .
3. 令 k = f + 1.
iii. 令 a 为 m 的前 k–f 个字符,
iv. 令 b 为其余 f 个字符
v. 令 m 为 串联三个字符串 a, ".", 和 b 的结果。
12. 返回串联字符串 s 和 m 的结果。
NOTE 2
对于某些值,toFixed 的输出可比 toString 的更精确,因为 toString 只打印区分相邻数字值的足够的有效数字。例如 ,
(1000000000000000128).toString() 返回 "1000000000000000100",
而 (1000000000000000128).toFixed(0) 返回 "1000000000000000128".
V8 的实现 (opens new window)的确遵照了这个规范。
另外,小数并不是都能精确表示的,你可以给 toFixed 传一个很大的参数(最大 100 位),查看这个值,比如
0.125.toFixed(100)
// "0.1250000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
0.135.toFixed(100)
// "0.1350000000000000088817841970012523233890533447265625000000000000000000000000000000000000000000000000"
我们可以看到 0.125 可以在 js 里精确表示,0.135 却不可以,另外你也可以使用 toPrecision 方法或者这个在线网站 (opens new window)查看具体精度。
好吧,总算搞明白了,在实际过程中一定要注意这个坑,特别是在换算金额等敏感数据时。欢迎留言 (opens new window)