該死的IEEE-754浮點數,說「約」就「約」,你的底線呢?以JS的名義來好好查查你

IEEE 754 表示:你盡管抓狂、罵娘,但你能完全避開我,算我輸。

一、IEEE-754浮點數捅出的那些婁子

首先我們還是來看幾個簡單的問題,能說出每一個問題的細節的話就可以跳過了,而如果只能泛泛說一句“因為IEEE754浮點數精度問題”,那么下文還是值得一看。

第一個問題是知名的0.1+0.2 != 0.3,為什么?菜鳥會告訴你“因為IEEE 754的浮點數表示標準”,老鳥會補充道“0.1和0.2不能被二進制浮點數精確表示,這個加法會使精度喪失”,巨鳥會告訴你整個過程是怎樣的,小數加法精度可能在哪幾步喪失,你能答上細節么?

第二個問題,既然十進制0.1不能被二進制浮點數精確存儲,那么為什么console.log(0.1)打印出來的確確實實是0.1這個精確的值?

第三個問題,你知道這些比較結果是怎么回事么?

//這相等和不等是怎么回事?
0.100000000000000002 ==
0.100000000000000010 // true

0.100000000000000002 ==
0.100000000000000020 // false

//顯然下面的數值沒有超過Number.MAX_SAFE_INTEGER的范圍,為什么是這樣?
Math.pow(10, 10) + Math.pow(10, -7) === Math.pow(10, 10) //  true
Math.pow(10, 10) + Math.pow(10, -6) === Math.pow(10, 10) //  false

追問一句,給出一個數,給這個數加一個增量,再和這個數比較,要保持結果是true,即相等,那么大約這個增量的數量級最大可以到多少,你能估計出來么?

第四個問題,旁友,你知道下面這段一直在被引用的的代碼么(這段代碼用于解決常見范圍內的小數加法以符合常識,比如將0.1+0.2結果精確計算為0.3)?你理解這樣做的思路么?但是你知道這段代碼有問題么?比如你計算268.34+0.83就會出現問題。

//注意函數接受兩個string形式的數
function numAdd(num1/*:String*/, num2/*:String*/) { 
    var baseNum, baseNum1, baseNum2; 
    try { 
        baseNum1 = num1.split(".")[1].length; 
    } catch (e) { 
        baseNum1 = 0; 
    } 
    try { 
        baseNum2 = num2.split(".")[1].length; 
    } catch (e) { 
        baseNum2 = 0;
    } 
    baseNum = Math.pow(10, Math.max(baseNum1, baseNum2)); 
    return (num1 * baseNum + num2 * baseNum) / baseNum; 
};

//看上去好像解決了0.1+0.2
numAdd("0.1","0.2"); //返回精確的0.3

//但是你試試這個
numAdd("268.34","0.83");//返回 269.16999999999996

那么多問題,還真是該死的IEEE-754,而這一切都源于IEEE-754浮點數本身的格式,以及“說「約」就「約」”(舍入)的規則,致使精度喪失,計算淪喪,作為一個前端,我們就從JS的角度來扒一扒。

二、端詳一下IEEE-754雙精度浮點的樣貌

所謂“知己知彼,百戰不殆”,要從內部瓦解敵人,就要先了解敵人,但為什么只選擇雙精度呢,因為知道了雙精度就明白了單精度,而且在JavaScript中,所有的Number都是以64-bit的雙精度浮點數存儲的,所以我們來回顧一下到底是怎么存儲的,以及這樣子存儲怎么映射到具體的數值。

IEEE754浮點數形式
IEEE754浮點數形式

二進制在存儲的時候是以二進制的“科學計數法”來存儲的,我們回顧下十進制的科學計數法,比如54846.3,這個數我們在用標準的科學計數法應該是這樣的:5.48463e4,這里有三部分,第一是符號,這是一個正數,只是一般省略正號不寫,第二是有效數字部分,這里就是5.48463,最后是指數部分,這里是4。以上就是在十進制領域下的科學計數法,換到二進制也是一樣,只是十進制下以10為底,二進制以2為底。

雙精度的浮點數在這64位上劃分為3段,而這3段也就確定了一個浮點數的值,64bit的劃分是“1-11-52”的模式,具體來說:

  • 就是1位最高位(最左邊那一位)表示符號位,0表示正,1表示負
  • 接下去11位表示指數部分
  • 最后52位表示尾數部分,也就是有效域部分

這里幺蛾子就很多了。首先“每個實數都有一個相反數”這是中學教的,于是符號位改變下就是一個相反數了,但是對于數字0來說,相反數就是自己,而符號位對于每一個由指數域和尾數域確定的數都是一視同仁,有正就有負,要么都沒有。所以這里就有正0和負0的概念,但是正0和負0是相等的,但是他們能反應出符號位的不同,和正零、負零相關的有意思的事這里不贅述。

然后,指數不一定要正數吧,可以是負數吧,一種方式是指數域部分也設置一個符號位,第二種是IEEE754采取的方式,設置一個偏移,使指數部分永遠表現為一個非負數,然后減去某個偏移值才是真實的指數,這樣做的好處是可以表現一些極端值,我們等會會看到。而64bit的浮點數設置的偏移值是1023,因為指數域表現為一個非負數,11位,所以 0 <= e <= 2^11 -1,實際的E=e-1023,所以 -1023 <= E <= 1024。這兩端的兩個極端值結合不同的尾數部分代表了不同的含義

最后,尾數部分,也就是有效域部分,為什么叫有效域部分,舉個栗子,這里有52個坑,但是你的數字由60個二進制1組成,不管怎樣,你都是不能完全放下的,只能放下52個1,那剩下的8個1呢?要么舍入要么舍棄了,總之是無效了。所以,尾數部分決定了這個數的精度。

而對于二進制的科學計數法,如果保持小數點前必須有一位非0的,那有效域是不是必然是1.XXXX的形式?而這樣子的二進制被稱為規格化的,這樣的二進制在存儲時,小數點前的1是默認存在,但是默認不占坑的,尾數部分就存儲小數點后的部分

問題來了,如果這個二進制小數太小了,那么會出現什么情況呢?對于一個接近于0的二進制小數,一味追求1.xxx的形式,必然導致指數部分會向負無窮靠攏,而真實的指數部分最小也就能表示-1023,一旦把指數部分逼到了-1023,還沒有到1.xxx的形式,那么只能用0.xxx的形式表示有效部分,這樣的二進制浮點數表示非規格化的

于是,我們整一個64位浮點數能表示的值由符號位s,指數域e和尾數域f確定如下,從中我們可以看到正負零、規格化和非規格化二進制浮點數、正負無窮是怎么表示的:

浮點數形式和數值的映射
浮點數形式和數值的映射

這里的(0.f)(1.f)指的是二進制的表示,都要轉化為十進制再去計算,這樣你就可以得到最終值。

回顧了IEEE754的64bit浮點數之后,有以下3點需要牢記的:

  1. 指數和尾數域是有限的,一個是11位,一個是52位
  2. 符號位決定正負,指數域決定數量級,尾數域決定精度
  3. 所有數值的計算和比較,都是這樣以64個bit的形式來進行的,拋開腦海中想當然的十進制

三、精度在哪里發生丟失

當你直接計算0.1+0.2時,你要知道“你大媽已經不是你大媽,你大爺也已經不是你大爺了,所以他們生的孩子(結果)出現問題就可以理解了”。這里的0.10.2是十進制下的0.1和0.2,當它們轉化為二進制時,它們是無限循環的二進制表示。

這引出第一處可能丟失精度的地方,即在十進制轉二進制的過程中丟失精度。因為大部分的十進制小數是不能被這52位尾數的二進制小數表示完畢的,我們眼中最簡單的0.1、0.2在轉化為二進制小數時都是無限循環的,還有些可能不是無限循環的,但是轉化為二進制小數的時候,小數部分超過了52位,那也是放不下的。

那么既然只有52位的有效域,那么必然超出52位的部分會發生一件靈異事件——閹割,文明點叫“舍入”。IEEE754規定了幾種舍入規則,但是默認的是舍入到最接近的值,如果“舍”和“入”一樣接近,那么取結果為偶數的選擇。

所以上面的0.1+0.2中,當0.1和0.2被存儲時,存進去的已經不是精確的0.1和0.2了,而是精度發生一定丟失的值。但是精度丟失還沒有完,當這個兩個值發生相加時,精度還可能進一步丟失,注意幾次精度丟失的疊加不一定使結果偏差越來越大哦。

第二處可能丟失精度的地方是浮點數參與計算時,浮點數參與計算時,有一個步驟叫對階,以加法為例,要把小的指數域轉化為大的指數域,也就是左移小指數浮點數的小數點,一旦小數點左移,必然會把52位有效域的最右邊的位給擠出去,這個時候擠出去的部分也會發生“舍入”。這就又會發生一次精度丟失。

所以就0.1+0.2這個例子精度在兩個數轉為二進制過程中和相加過程中都已經丟失了精度,那么最后的結果有問題,不能如愿也就不奇怪了,如果你很想探究具體這是怎么計算的,文末附錄的鏈接能幫助你。

四、疑惑:0.1不能被精確表示,但打印0.1它就是0.1啊

是的,照理說,0.1不能被精確表示,存儲的是0.1的一個近似值,那么我打印0.1時,比如console.log(0.1),就是打印出了精確的0.1啊。

事實是,當你打印的時候,其實發生了二進制轉為十進制,十進制轉為字符串,最后輸出的。而十進制轉為二進制會發生近似,那么二進制轉為十進制也會發生近似,打印出來的值其實是近似過的值,并不是對浮點數存儲內容的精確反映。

關于這個問題,StackOverflow上有一個回答可以參考,回答中指出了一篇文獻,有興趣的可以去看:

How does javascript print 0.1 with such accuracy?

五、相等不相等,就看這64個bit

再次強調,所有數值的計算和比較,都是這樣以64個bit的形式來進行的,當這64個bit容不下時,就會發生近似,一近似就發生意外了。

有一些在線的小數轉IEEE754浮點數的應用對于驗證一些結果還是很有幫助的,你可以用這個IEEE-754 Floating-Point Conversion工具幫你驗證你的小數轉化為IEEE754浮點數之后是怎么個鬼樣。

來看第一部分中提出兩個簡單的比較問題:

//這相等和不等是怎么回事?
0.100000000000000002 ==
0.1  //true

0.100000000000000002 ==
0.100000000000000010 // true

0.100000000000000002 ==
0.100000000000000020 // false

當你把0.10.1000000000000000020.100000000000000010.10000000000000002用上面的工具轉為浮點數后,你會發現,他們的尾數部分(注意看尾數部分最低4位,其余位都是相同的),前三個是相同的,最低4位是1010,但是最后一個轉化為浮點數尾數最低4位是1011。

這是因為它們在轉為二進制時要舍入部分的不同可能造成的不同舍入導致在尾數上可能呈現不一致,而比較兩個數,本質上是比較這兩個數的這64個bit,不同即是不等的,有一個例外,+0==-0

再來看提到的第二個相等問題:

Math.pow(10, 10) + Math.pow(10, -7) === Math.pow(10, 10) //  true
Math.pow(10, 10) + Math.pow(10, -6) === Math.pow(10, 10) //  false

為什么上面一個是可以相等的,下面一個就不行了,首先我們來轉化下:

Math.pow(10, 10) =>
指數域 e =1056 ,即 E = 33
尾數域 (1.)0010101000000101111100100000000000000000000000000000

Math.pow(10, -7) =>
指數域 e =999 ,即 E = -24

Math.pow(10, -6) =>
指數域 e =1003 ,即 E = -20
尾數域 (1.)0000110001101111011110100000101101011110110110001101

可以看到1e10的指數是33次,而Math.pow(10, -7)指數是-24次,相差57次,遠大于52,因此,相加時發生對階,早就把Math.pow(10, -7)近似成0了

Math.pow(10, -6)指數是-20次,相差53次,看上去大于52次,但有一個默認的前導1別忘了,于是當發生對階,小數點左移53位時,這一串尾數(別忘了前導1)正好被擠出第52位,這時候就會發生”舍入“,舍入結果是最低位,也就是bit0位變成1,這個時候和Math.pow(10, 10)相加,結果的最低位變成了1,自然和Math.pow(10, 10)不相等。

你可以用這個IEEE754計算器來驗證結果。

六、淺析數值和數值精度的數量級對應關系

承接上面的那個結果,我們發現當數值為10的10次時,加一個-7數量級的數,對于值沒有影響,加一個-6數量級的數,卻對值由影響,這里的本質我們也是知道的:

這是由于計算時要對階,如果一個小的增量在對階時最高有效位右移(因為小數點在左移)到了52位開外,那么這個增量就很可能被忽略,即對階完尾數被近似成0。

換句話說,我們可以說對于1010數量級,其精確度大約在10-6數量級,那么對于109、108、100等等數量級的值,精確度又大約在多少呢?

有一張圖很好地說明了這個對應關系:

數值數量級和精確度數量級對應關系
數值數量級和精確度數量級對應關系

這張圖,橫坐標表示浮點數值數量級,縱坐標表示可以到達的精度的數量級,當然這里橫坐標對應的數值數量級指的是十進制表示下的數量級。

比如你在控制臺測試(.toFixed()函數接受一個20及以內的整數n以顯示小數點后n位):

0.1.toFixed(20) ==> 0.10000000000000000555(這里也可以看出0.1是精確存儲的),根據上面的圖我們知道0.1是10-1數量級的,那么精確度大約在10-17左右,而我們驗證一下:

//動10的-18數量級及之后的數字,并不會有什么,依舊判定相等
0.10000000000000000555 ==
0.10000000000000000999  //true
//動10的-17數量級上的數字,結果馬上不一樣了
0.10000000000000000555 ==
0.10000000000000001555  //false

從圖上也可以看到之前的那個例子,1010數量級,精確度在10-6數量級。

也就是說,在IEEE754的64位浮點數表示下,如果一個數的數量級在10X,其精確度在10Y,那么X和Y大致滿足:

X-16=Y

知道這個之后我們再回過頭來看ECMA在定義的Number.EPSILON,如果還不知道有這個的存在,可以控制臺去輸出下,這個數大約是10-16數量級的一個數,這個數定義為”大于1的能用IEEE754浮點數表示為數值的最小數與1的差值“,這個數用來干嘛呢?

0.1+0.2-0.3<Number.EPSILON是返回true的,也就是說ECMA預設了一個精度,便于開發者使用,但是我們現在可以知道這個預定義的值其實是對應 100 數量級數值的精確度,如果你要比較更小數量級的兩個數,預定義的這個Number.EPSILON就不夠用了(不夠精確了),你可以用數學方式將這個預定義值的數量級進行縮小。

七、麻煩稍小的整數提供一種解決思路

那么怎樣能在計算機中實現看上去比較正常和自然的小數計算呢?比如0.1+0.2就輸出0.3。其中一個思路,也是目前足夠應付大多數場景的思路就是,將小數轉化為整數,在整數范圍內計算結果,再把結果轉化為小數,因為存在一個范圍,這個范圍內的整數是可以被IEEE754浮點形式精確表示的,換句話說這個范圍內的整數運算,結果都是精確的,而大部分場景下這個數的范圍已經夠用,所以這種思路可行。

1. JS中數的“量程”和“精度”

之所以說一個范圍,而不是所有的整數,是因為整數也存在精確度的問題,要深刻地理解,”可表示范圍“和”精確度“兩個概念的區別,就像一把尺子的”量程“和”精度“

JS所能表示的數的范圍,以及能表示的安全整數范圍(安全是指不損失精確度)由以下幾個值界定:

//自己可以控制臺打印看看
Number.MAX_VALUE => 能表示的最大正數,數量級在10的308次
Number.MIN_VALUE => 能表示的最小正數,注意不是最小數,最小數是上面那個取反,10的-324數量級

Number.MAX_SAFE_INTEGER => 能表示的最大安全數,9開頭的16位數
Number.MIN_SAFE_INTEGER => 能表示的最小安全數,上面那個的相反數

為什么超過最大安全數的整數都不精確了呢?還是回到IEEE754的那幾個坑上,尾數就52個坑,有效數再多,就要發生舍入了。

2. 一段有瑕疵的解決浮點計算異常問題的代碼

因此,回到解決JS浮點數的精確計算上來,可以把待計算的小數轉化為整數,在安全整數范圍內,再計算結果,再轉回小數。

所以有了下面這段代碼(但這是有問題的):

//注意要傳入兩個小數的字符串表示,不然在小數轉成二進制浮點數的過程中精度就已經損失了
function numAdd(num1/*:String*/, num2/*:String*/) { 
    var baseNum, baseNum1, baseNum2; 
    try { 
        //取得第一個操作數小數點后有幾位數字,注意這里的num1是字符串形式的
        baseNum1 = num1.split(".")[1].length; 
    } catch (e) {
        //沒有小數點就設為0 
        baseNum1 = 0; 
    } 
    try { 
        //取得第二個操作數小數點后有幾位數字
        baseNum2 = num2.split(".")[1].length; 
    } catch (e) { 
        baseNum2 = 0;
    }
    //計算需要 乘上多少數量級 才能把小數轉化為整數 
    baseNum = Math.pow(10, Math.max(baseNum1, baseNum2)); 
    //把兩個操作數先乘上計算所得數量級轉化為整數再計算,結果再除以這個數量級轉回小數
    return (num1 * baseNum + num2 * baseNum) / baseNum; 
};

思路沒有問題,看上去也解決了0.1+0.2的問題,用上面的函數計算numAdd("0.1","0.2")時,輸出確實是0.3。但是再多試幾個,比如numAdd("268.34","0.83"),輸出是269.16999999999996,瞬間爆炸,這些代碼一行都不想再看。

其實仔細分析一下,這個問題還是很好解決的。問題是這么發生的,有一個隱式的類型轉換,上面的num1和num2傳入都是字符串類型的,但是在最后return的那個表達式中,直接參與計算,于是num1和num2隱式地從String轉為Number,而Number是以IEEE754浮點數形式儲存的,在十進制轉為二進制過程中,精度會損失

我們可以在上面代碼的return語句之上加上這兩句看看輸出是什么:

console.log(num1 * baseNum);
console.log(num2 * baseNum);

你會發現針對numAdd("268.34","0.83")的例子,上面兩行輸出26833.99999999999683。可以看到轉化為整數的夢想并沒有被很好地實現

要解決這個問題也很容易,就是我們顯式地讓小數“乖乖”轉為整數,因為我們知道兩個操作數乘上計算所得數量級必然應該是一個整數,只是由于精度損失放大導致被近似成了一個小數,那我們把結果保留到整數部分不就可以了么?

也就是把上面最后一句的

return (num1 * baseNum + num2 * baseNum) / baseNum;
改為
return (num1 * baseNum + num2 * baseNum).toFixed(0) / baseNum;

分子上的.toFixed(0)表示精確到整數位,這基于我們明確地知道分子是一個整數

3. 局限性和其他可能的思路

這種方式的局限性在于我要乘上一個數量級把小數轉為整數,如果小數部分很長呢,那么通過這個方式轉化出的整數就超過了安全整數的范圍,那么計算也就不安全了。

不過還是一句話,看使用場景進行選擇,如果局限性不會出現或者出現了但是無傷大雅,那就可以應用。

另一種思路是將小數轉為字符串,用字符串去模擬,這樣子做可適用的范圍比較廣,但是實現過程會比較繁瑣。

如果你的項目中需要多次面臨這樣的計算,又不想自己實現,那么也有現成的庫可以使用,比如math.js,感謝這個美好的世界吧。

八、小結

作為一個JS程序員,IEEE754浮點數可能不會經常讓你心煩,但是明白這些能讓你在以后遇到相關意外時保持冷靜,正常看待。看完全文,我們應該能明白IEEE754的64位浮點數表示方式和對應的值,能明白精度和范圍的區別,能明白精度損失、意外的比較結果都是源自于那有限數量的bit,而不用每次遇到類似問題就發一個日經的問題,不會就知道“IEEE754”這一個詞的皮毛卻說不出一句完整的表達,最重要是能夠心平氣和地罵一句“你這該死的IEEE754”后繼續coding...

如有紕漏煩請留言指出,謝謝。

附:感謝以下內容對我的幫助

實現js浮點數加、減、乘、除的精確計算
IEEE-754 Floating-Point Conversion IEEE-754浮點數轉換工具
IEEE754 浮點數格式 與 Javascript number 的特性
Number.EPSILON及其它屬性

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,797評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,179評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,628評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,642評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,444評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,948評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,040評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,185評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,717評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,794評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,418評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,414評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,750評論 2 370

推薦閱讀更多精彩內容