ECMAScript中的Number Type與 IEEE 754-2008

JunYu發表於2019-05-02

introduction

稍微深入瞭解一下JavaScript浮點數的開發者都會知道浮點數的誤差問題,也就是說IEEE754-2008的浮點數誤差。 常見的案例為: 0.1 + 0.2 = 0.30000000000000004 無論是google一下或者baidu一下,這類文章層出不窮,但是很多都是淺嘗即止,無法讓我能夠邏輯通順的理解。在所有閱讀的中文資料當中,我覺得較優秀的是camsong同學的抓住資料的尾巴,有些圖是直接借鑑該同學的(會註明),但是這篇文章的一個問題是,對於某些數學上的區間表示不清楚,比如究竟是開區間還是閉區間。因此,我寫下了該篇文章。 主要閱讀的資料來源: ECMAScript 2015, ECMAScript 2018, wiki, etc.

首先,給出大家整篇內容的思維導圖:

ECMAScript中的Number Type與 IEEE 754-2008

前置知識

  1. 代數數學告訴我們實數(real number)包含有理數(rational number)和無理數:
  • 有理數是一個整數a和一個正整數b的比(a/b),是整數和分數的集合,整數可以看成分母為1的分數,有理數的小數部分是有限的或為無限迴圈的數。
  • 無理數是所有不是有理數字的實數,常見的無理數有:尤拉數e,黃金比例φ,數字π等.

很明顯,在後面會知道,現代計算機使用有限的bits來儲存浮點數,因此只能精確的表示實數中小數部分為有限的有理數,對於其他的數學實數數字只能是近似等於而已。借用網路上的一張圖表示即是:

ECMAScript中的Number Type與 IEEE 754-2008

結論1:數學中的實數是連續的直線,而計算機中浮點數是實數直線的間斷的點。

  1. 在1985年以前,程式語言對於浮點數的儲存各自有各自的標準,而在1985年後,基本都採用IEEE754 arithmetic標準,目前IEEE754最新版本為IEEE754-2008, 而ECMAScript 2015以後Number Type遵循 double-precision 64-bit format IEEE754-2008 arithmetic. 具體的章節為 6.1.6 The Number Type

需要注意的是,任何標準的實現可能和標準本身有差別,而ECMAScript Number Type在描述Number type和IEEE754-2008在對double-precision 64-bit format的描述有稍微的不同, 具體在後面詳細講解

  1. 遵循IEEE-754的常見語言實現,比如 C and C++, Common Lisp, Java, JavaScript等。這類語言常見的關於小數的問題有兩類:
  • 資料精度丟失
  • 大數危機(安全整數範圍)
  1. 我們回顧一下計算機組成原理當中,關於二進位制和十進位制的轉換,主要分為整數部分和小數部分:

ECMAScript中的Number Type與 IEEE 754-2008

IEEE754 64-bit double precision 浮點數

首先看下浮點數的儲存方式,64bits可以分為3個部分:

  • 符號位S: 第一位是正負數符號位(sign), 0表示正數,1表示負數。這也是為什麼會出現+0-0的原因。
  • 指數位E:中間的11位儲存指數(exponent),用來表示次方數
  • 尾數位M:最後的52位是尾數(mantissa), 超出的部分採用進1舍0.

採用wiki上的圖表示就是:

ECMAScript中的Number Type與 IEEE 754-2008
轉換成數學公式為:

ECMAScript中的Number Type與 IEEE 754-2008

  1. 上述的公式很明顯遵循科學計數法的規範,十進位制0<M<10,二進位制位0<M<2,也就是對於二進位制來講整數部分只能是1,所以為了更高的精度表示,我們在計算機中儲存的時候可以捨去整數部分的1,只保留後面的小數部分。
11.125 轉換成二進位制為 1101.001 轉換成科學表示式  1.101001* 2^3
複製程式碼
  1. 我們來看指數位E,E是一個無符號整數,取值範圍是[0, 2047],但是我們通常用科學計數法表示資料時指數是可以為負數的,因此約定一箇中間數(exponent bias)1023表示為0,因此[1,1022]表示指數位負,[1024,2046]表示為正(這裡注意指數位為0和2047被用作特殊數字用途)。最後的公式變化為:

ECMAScript中的Number Type與 IEEE 754-2008

  1. 下面我們用0.1來解釋浮點誤差的原因:
  • 0.1轉成二進位制表示為0.0001100110011001100(1100迴圈), 轉成科學計數法為1.100110011001100 * 2^-4,因此E= -4+1023 = 1019;M捨去捨去首位的1,小數點後第53位為1,遵循進1舍0,得到最後的結果為:

ECMAScript中的Number Type與 IEEE 754-2008

我們將上面的二進位制數字在數學上轉化成十進位制為: 0.100000000000000005551115123126, 即出現了經典的浮點數誤差.

  1. 下面我們來看看M,也就是精度。53-bit significand precision轉換成10進位制能夠保證15到17位的significant decimal digits precision(2−53 ≈ 1.11 × 10−16), IEE754-2008對於邊界情況有如下:
  • 如果一個十進位制有最多15個有效數字,轉換成IEEE double-precision表示,然後再轉換成十進位制,最後的結果必須和最開始的十進位制相同
  • 如果一個IEEE 754 double-precision數字轉換成一個十進位制(至少17 significant digits),然後再轉換成double-precision 表示,最後的結果也必須和最開始的二進位制相同。

因此,53-bit的精度轉換成10進製為16個十進位制數字(53log10(2) 約等於15.955).

安全整數

首先,我們給出結論,ECMAScript的安全整數範圍為 [-2^53, 2^53],那麼為什麼呢?

  1. IEE754 64-bit無法表示2^53 + 1,因為尾數只有52位,共有2^53個選擇,而2^53 + 1,轉換成科學計數法 2^53 * (1+ 2^-53),IEEE754 64-bit是沒有辦法表示的。這樣明顯是不安全的。

ECMAScript中的Number Type與 IEEE 754-2008

ECMAScript中的Number Type與 IEEE 754-2008

ECMAScript中的Number Type與 IEEE 754-2008

但是對於2^53 + 2,轉換成科學計數法 2^54 * (1+ 2^-52)可以用IEEE754 64-bit表示。

  1. 根據上面的現象,IEEE754能夠表示的浮點數可以抽象為:
  • [2^53, 2^54] 之間的數,IEEE754 64-bit能夠表示的數都是可以被2整除的,兩數之間的間隔為2.
  • [2^54, 2^55] 之間的數的間隔為4
  • 那麼 [2^51, 2^52] 的數字與數字的間隔為0.5
  • 數學歸納法總結一下:The spacing as a fraction of the numbers in the range from 2^n to 2^n+1 is 2^(n−52).

具體實現的64-bit precision案例

ECMAScript中的Number Type與 IEEE 754-2008

  1. 這裡可以看出指數E為0和2047是有特殊含義的,E為0除了表示+0和-0,還表示subnormal numbers. E為2047除了表示+Infinity-Infinity,還表示各種NaNNaN的個數為2^53 - 2個。

  2. 引入了兩個概念:subnormal double和normal double,下面的圖基本能夠表達兩者之間的數學含義:

ECMAScript中的Number Type與 IEEE 754-2008
在計算機系統當中,一個未增強的floating-point system只能包含normalized numbers(上圖中的紅色),而允許subnormal numbers(藍色)擴充套件了系統的數字範圍,處於系統underflow gap(下溢)和0之間。下面用案例詳細解釋了兩者之間的區別:

  • 在normal floating-point value當中,我們通過exponent(指數)的偏移來移除尾數(significand)的0(比如0.0123 = 1.23 * 10^-2)。而subnormal numbers在significand中使用了leading zero,什麼是leading zero具體看下面。
  • 在IEEE floating-point number當中,比如一個positive normalized number,通常可以表示為m~0~.m~1~m~2~...m~p-1~(這裡~2~表示的下標,m代表一個sidnificant digit, p是精度,m~0~不為0)。對於一個subnormal number, exponent是可能表示的最小的exponent,zero是significand digit (0.m~1~m~2~...m~p-1~),也就是說所有的subnormal number都比最小的normal number更接近0.

ECMASCript2015 specification: 6.1.6 Number Type

在ECMAScript規範當中,並沒有直接用s * 2^(e-1023) * M這種表達方式,而是將M通過位移轉換成整數,也就是s * 2 ^ (e-1075) * M. 這是需要注意的一點。 具體規範當中總結出來以下幾點:

  1. 64 bit去掉一位符號位,可以表達為 2^64 個不同的值,而IEEE754中2^53 - 2個 "not-a-number"值在ECMAScript中統一表達為NaN,也就是說,Number type有(2^64 - 2^53 + 3)個不同的values。
  2. 兩個特殊的值,為Infinity-Infinity, 這裡兩個值的exponent轉換為二進位制位11個1,mantissia全為0(二進位制).具體可以看上一小節的圖篇案例。
  3. 根據上面兩點推斷出,有2^64 - 2^53個finite numbers. 一半是positive numbers,另一半是negative numbers。也就是說這類值包含有positive zeronegative zero兩個0值.
  4. 也就是說,有2^64 - 2^53-2個非0的finite values,而這類值可以分為兩類:
    • normalized value :共包含2^64 - 2^54個值,這類值的form是: s * M * 2^e, s是+1-1, m是positive integer([2^52, 2^53)),e的範圍是[-1074, 951],這裡可以看出ECMAScript的實現沒有采用exponent bias的表達方式
  • denormalized number: 共有2^53 - 2個,公式仍然是: s * M * 2^e, s是+1-1, m是positive integer((0, 2^52)),e的值為-1074
  1. 我們知道,實際上按照二進位制轉十進位制計算,由於E的最大值是1023(除了特殊的兩個e取值),也就是說實際上可以表示的最大整數位2^1024 - 1,我們知道這超過了最大安全整數範圍。對於不能用IEEE-754 64-bit表示的值採取round to nearest, ties to even 的模式,round to nearest我們理解,但是什麼是ties to even呢? 舉個例子: 9007199254740995在IEEE754 64-bit中是無法表示的,因此會被繫結到9007199254740996上面去。

下面為什麼0.1 + 0.2 = 0.30000000000000004?

我們來看計算步驟:

// 0.1 和 0.2 都轉化成二進位制後再進行運算
0.00011001100110011001100110011001100110011001100110011010 +
0.0011001100110011001100110011001100110011001100110011010 =
0.0100110011001100110011001100110011001100110011001100111

轉換為IEEE754 double point為 1.0 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 0110 100 * 2^(-2),如果用二進位制轉成十進位制為(0.3 + 5/(100 * 2^50)).去小數點後面17位精度為0.30000000000000004,
這裡取的是17位而不是16位,是IEEE754的規範中的計算結果
複製程式碼

ECMAScript中的Number Type與 IEEE 754-2008

為什麼x=0.1能得到0.1

在ECMAScript當中,沒有使用IEEE754的exponent bias,而是把mantissa當中是整數,exponent為[-1024,951],而2^53的十進位制表示最多16位有效數字,也是最大表示的進度:

ECMAScript中的Number Type與 IEEE 754-2008

我們該如何處理這類浮點誤差問題

首先,讓我回想起在刷LeetCode題的時候,有一類問題即大數問題,當要計算的數超出了語言的上限,那時候我們是用陣列來處理的。

首先引入兩個方法, Number.prototype.toPrecisionNumber.prototype.toFixed,兩者都能夠對於多餘數字做湊整處理:

  • toPrecision:The toPrecision() method returns a string representing the Number object to the specified precision,是用來處理精度的,對於精度數學中表示從左只右第一個不為0的數字開始算起
  • toFixed: 從小數點後指定位數取整。

對於使用toFixed來做湊整處理,我們需要注意一些特殊案例: 比如(1.005).toFixed(2)返回1.00,因為1.005實際為1.0049999999999999999 而對於浮點誤差問題,我們通常分成兩類解決方案,解決方案來自camsong同學:

  1. 對於資料展示類 對於需要展示的數字使用toPrecision處理後,用parseInt轉成數字再顯示:
function strip(num, precision = 12) {
  return +parseFloat(num.toPrecision(precision));
}
複製程式碼

這裡camsong同學採取12作為預設精度,是經驗的選擇,因為一般選12能處理大部分問題

  1. 對於資料運算類 先將小數轉成整數再運算:
/**
 * 精確加法
 */
function add(num1, num2) {
  const num1Digits = (num1.toString().split('.')[1] || '').length;
  const num2Digits = (num2.toString().split('.')[1] || '').length;
  const baseNum = Math.pow(10, Math.max(num1Digits, num2Digits));
  return (num1 * baseNum + num2 * baseNum) / baseNum;
} 
複製程式碼

並且該同學也提供了相關地庫:number-precision

一些其他著名的庫包括但不限於: Math.js, big.js等

reference

  1. IEEE754 double 視覺化
  2. 抓住資料的小尾巴 - JS浮點數陷阱及解法
  3. double-precision floating-point format
  4. denormal number
  5. floating point arithemtic
  6. What Every Computer Scientist Should Know About Floating-Point Arithmetic
  7. ECMAScript Number Type

相關文章