# 数值修约

# 简介

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:

  1. Let n be ? ToNumber(x).
  2. If n is NaN, +∞𝔽, -∞𝔽, or an integral Number, return n.
  3. If n < 0.5𝔽 and n > +0𝔽, return +0𝔽.
  4. If n < +0𝔽 and n ≥ -0.5𝔽, return -0𝔽.
  5. Return the integral Number closest to n, preferring the Number closer to +∞ in the case of a tie.

我翻译一下:返回最接近且为整数的数值。如果两个整数相等地接近x,则结果是更接近的数值。如果已经是整数,结果是。 当 Math.round 方法传入一个参数 , 将执行以下步骤:

  1. 隐式类型转化为数字
  2. 如果 为 NaN / Infinity / -Infinity, 或者是整数,返回
  3. 如果 ,返回 +0
  4. 如果 , 返回 -0
  5. 返回最接近 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)