【學習筆記】爲何 0.1 + 0.2 !== 0.3

背景

在瀏覽器控制檯窗口輸入如下兩行代碼,結果出乎意料git

0.1 + 0.2 > 0.3  // true
0.1 + 0.2 = 0.30000000000000004
0.1 * 0.1 = 0.010000000000000002
複製代碼

前置知識

  • 在計算機的世界裏,應該是隻有二進制數據的,不是 0 就是 1,那麼爲了表達生活中最爲常見的十進制數據,就會有個轉換過程

十進制轉爲二進制

  • 十進制轉換爲二進制這個過程總體總結就是:

    整數採用整數除 2 取餘,直到商爲 0 時爲止,將餘數逆序排列,小數採用小數部分乘以 2,取整,直到獲得小數部分 0 或者達到所要求的精度爲止,將整數順序排列chrome

二進制轉爲十進制

// 以二進制 10101101.1101 爲例 
// 針對整數部分 10101101 計算邏輯以下
// ← 從右往左
1 * 2^0 + 0 * 2^1 + 1 * 2^2 + 1 * 2^3 + 0 * 2^4 + 1 * 2^5 + 0 * 2^6 + 1 * 2^7
= 1 + 0 + 4 + 8 + 0 + 32 + 0 + 128
= 173

// 小數部分 1101 計算邏輯以下
// 從左往右 →
1 * 2^-1 + 1 * 2^-2 + 0 * 2^-3 + 1 * 2^-4
= 1/2 + 1/4 + 0 + 1/16
= 13/16
= 0.8125
複製代碼

科學計數法

  • 十進制 173.8125 的科學計數法爲 1.738125 * 10^2
  • 十進制 173.8125 對應的二進制 10101101.1101,進一步可使用二進制的科學計數法來表示,對應的二進制科學計數法爲 1.01011011101 * 2^7,跟十進制相似,將底數 10 換爲了 2,7 則表明小數點往右多少位

    重點:1.01011011101 * 2^7 爲二進制,將其轉換爲 10 進制的過程爲,先將 1.01011011101 作爲 2 進制轉換爲 10 進制,獲得 1.35791015625,而後將其乘以 2^7 (也就是 1.35791015625 * 128),最後獲得的十進制爲 173.8125瀏覽器

二進制的邊界問題

n 位二進制可表示的最大數值範圍,是 n 位的每一位都爲 1,對應十進制爲 2^n - 1安全

// 8 位二進制 11111111 可表示的最大值,有兩種計算方法
// 一、累加
1*2^0 + 1*2^1 + 1*2^2 + 1*2^3 + 1*2^4 + 1*2^5 + 1*2^6 + 1*2^7 = 127
// 二、2^n - 1
2^8 - 1 = 127
複製代碼

IEEE 754 規範-雙精度(64位)

  • 衆所周知 JS 僅有 Number 這個數值類型,而其嚴格遵循 ECMAScript 規範定義的 IEEE754 64 位雙精度浮點數規則。而浮點數表示方式具備如下特色:markdown

    • 浮點數可表示的值範圍比同等位數的整數表示方式的值範圍要大得多
    • 浮點數沒法精確表示其值範圍內的全部數值,而有符號和無符號整數則是精確表示其值範圍內的每一個數值
    • 浮點數只能精確表示 m*2e 的數值
    • 當 biased-exponent 爲 2e-1-1 時,浮點數能精確表示該範圍內的各整數值
    • 當 biased-exponent 不爲 2e-1-1 時,浮點數不能精確表示該範圍內的各整數值

    因爲部分數值沒法精確表示(存儲),因而在運算統計後誤差會愈見明顯函數

  • IEEE 754 浮點數由三個部分組成,分別是 sign bit (符號位)、exponent bias (指數偏移值) 和 fraction (分數值),所以一個 JavaScript 的 number 表示二進制應該是以下格式: image.png image.pngoop

    • sign bit:1bit,0 表示正數,1 表示負數
    • exponent bias:11bit,表示次方數,在(二進制的)科學計數法中定義 2 的多少次冪,須要移動的位數;因爲會存在正負數,因此這裏用了一個偏移的方式處理,即真正的指數 +1023,這樣的話就表示了【-1023 ~ 1024】;而 -1023 也就是全 0,1024 就是全 1
    • fraction:表示精確度(小數部分,規範中會省略個位數上的 1 ),52bit,這裏須要注意的是因爲小數點前面 1 位必須爲 1(隱藏位),因此其實是 52+1=53 位
  • 一個浮點數 (Value) 的表示其實能夠這樣表示: value = sign * exponent * fraction性能

  • 十進制轉換爲 IEEE 754 標準主要轉換過程主要經歷 3 個過程ui

    • 轉換爲二進制表示
    • 將轉換後的二進制經過科學計數法表示
    • 將經過科學計數法表示的二進制轉換爲 IEEE 754 標準表示
  • 十進制 0.1 轉換結果以下編碼

    (1)將 0.1 轉換爲二進制 0.00011001100110011001100110011001100110011001100110011001(1001 無限循環)
    (2)將上述二進制轉換爲科學計數法獲得:2^-4 * 1.1001100110011001100110011001100110011001100110011001(1001無限循環)
    (3)從科學計數法中能夠獲得 sign 值爲 0、exponent 值爲 -4,-4 + 1023(固偏移定值,64 位的狀況下爲 1023)= 1019 再轉換爲 11 位二進制爲 01111111011 (4)fraction 爲科學計數法小數點後面的值 1001100110011001100110011001100110011001100110011010(fraction 最長爲 52 位,若小數點後的長度不夠 52 位用 0 補齊,但這裏超過了 52 位因此這裏產生了截斷,截斷時因爲第 53 位爲 1 因此會產生進位,小數點左邊的 1 計算機不會自動存儲,在再次換算爲 10 進制時會自動加上小數點左邊的 1)
    (5)最終獲得 0.1 存儲在計算機中的二進制爲:0 01111111011 1001100110011001100110011001100110011001100110011010

    image.png

    在第(3)步中能夠看到 0.1 在存儲的過程當中被截斷了,由於計算機最多隻能存 52 位,因此這裏就產生了精度誤差,這樣當再次將二進制換算爲十進制時就不是原來的值

    能被轉化爲有限二進制小數的十進制小數的最後一位必然以 5 結尾(由於只有 0.5 * 2 才能變爲整數,即二進制能精確地表示位數有限且分母是 2 的倍數的小數),因此十進制中一位小數 0.1 ~ 0.9 當中除了 0.5 外的值在轉化成二進制的過程當中都丟失了精度

  • 將上圖中的二進制再次轉換爲十進制的的步驟:

    • sign 爲 0 表明正數 +
    • 指數偏移值(exponent)爲 01111111011 轉換爲十進制爲 1019,這時還要減去以前加上的固定偏移值 1023 獲得最終指數的值爲 -4
    • 小數點右側的二進制(significand)爲1001100110011001100110011001100110011001100110011010,因爲存儲時省略了小數點左邊的 1 因此須要加上,獲得1.1001100110011001100110011001100110011001100110011010
    • 最終用上面的公式帶入獲得 value = +2^-4 * 1.1001100110011001100110011001100110011001100110011010 最後將這串二進制轉換爲 10 進製爲 0.100000000000000005551115123126 便可
  • 因此前面的公式具體一點能夠寫成下面這樣:其中的 e 爲指數偏移值 image.png

  • 前面計算指數偏移值時,會加上一個固定偏移值 1023,該值的計算公式以下:
    image.png
    其中的 e 爲存儲指數的比特的長度,在 64 位雙精度二進制中存儲指數的比特長度 e 爲 11,從而能夠計算獲得該固定偏移值爲 1023

  • 採用指數的實際值(例如前面的 -4)加上固定偏移值的辦法表示浮點數的指數,好處是能夠用長度爲 e 個比特的無符號整數來表示全部的指數取值,這使得兩個浮點數的指數大小的比較更爲容易,實際上能夠按照字典次序比較兩個浮點表示的大小

    指數的實際值可能爲正可能爲負,因此 11 比特中須要有一位用來表示符號位,若是採用補碼錶示的話,全體符號位 sign 和指數自身的符號位將致使不能簡單的進行大小比較。因此就採用了這種加上固定偏移值的方式,中文稱做階碼

  • 因此 64 位雙精度二進制中指數部分原本能表示的範圍爲 -1023 ~ +1023(2^10 - 1,第11位爲符號位),在加上一個固定偏移值 1023 後,指數偏移值範圍就爲 0 ~ 2046,這樣指數偏移值都爲正數,在存儲時就不須要關心符號的問題了,咱們就能夠直接用 11 位來存儲指數偏移值,最終存儲時指數偏移值的範圍就爲 0 ~ 2^11 - 1(2047)

  • 另外在從新轉換爲 10 進制時,爲了還原指數實際的值,指數偏移值須要減去固定偏移值 1023,最終指數在未加上固定偏移值前的的實際值的範圍爲 -1023 ~ 1024,其中 -1023 用來表示 0,1024 用來表示無窮,除去這兩個值指數的範圍爲 -1022 ~ 1023

  • 前面的公式還須要在細分一下,一共有兩個公式:

    • 當指數偏移值不爲 0 時,使用下面的公式 image.png

    • 當指數偏移值爲 0 時,使用下面的公式 image.png

      sign e 指數偏移值 = 實際值+固定偏移1023 fraction 計算過程 JS 中的值
      0 11111111110(1023+1023) 1111111111111111111111111111111111111111111111111111 (-1)^0 x 2^1024=1.7976931348623157e+308 Number.MAX_VALUE
      0 00000000000(-1023+1023) 0000000000000000000000000000000000000000000000000001 (-1)^0 x 2^-52 x 2^-1022=5e-324 Number.MIN_VALUE 最小的正數
      0 10000110011(52+1023) 1111111111111111111111111111111111111111111111111111 (-1)^0 x 2^53=9007199254740991 Number.MAX_SAFE_INTEGER
      1 10000110011(52+1023) 1111111111111111111111111111111111111111111111111111 (-1)^1 x 2^53=-9007199254740991 Number.MIN_SAFE_INTEGER
      0 11111111111(1024+1023) 0000000000000000000000000000000000000000000000000000 (-1)^0 x 1 x 2^1024 Infinity 正無窮
      1 11111111111(1024+1023) 0000000000000000000000000000000000000000000000000000 (-1)^1 x 1 x 2^1024 -Infinity 負無窮
      0 00000000000 0000000000000000000000000000000000000000000000000000 (-1)^0 x 0 x 2^-1022 0
      1 00000000000 0000000000000000000000000000000000000000000000000000 (-1)^1 x 0 x 2^-1022 -0

    在計算過程當中,有一步沒有寫出來,以第一行爲例,看看 2^1024 是怎麼來的:
    1.(52個1) × 2^(2046 - 1023) = 1.(52個1) × 2^1023 = (53 個 1) × 2^(1023-52) = 53 位二進制表示的十進制爲 (2^53 - 1) × 2^971
    還有一種方法就是直接將 1.fraction 轉換爲 10 進制再 ✖️ 指數部分,如上面也能夠寫做 1.(52個1) × 2^(2046 - 1023) = 1.9999999999999998 * 2^1023

  • 大數危機,爲何 2^53-1 是最大安全整數呢?比它大會怎樣? image.png

    • 以 2^53 來講明一下爲何 2^53-1 是最大安全整數,安全在哪裏

      2^53 轉二進制 => 100000000000000000000000000000000000000000000000000000(53個0)
      轉爲科學計數法 => 1.00000000000000000000000000000000000000000000000000000(53個0)×2^53
      存入計算機 => 尾數位只有52位因此截掉末尾的0只能存52個0

      2^53+1 轉二進制 => 100000000000000000000000000000000000000000000000000001(52個0)
      轉爲科學計數法 => 1.00000000000000000000000000000000000000000000000000001(52個0)×2^53
      存入計算機 => 尾數位只有52位因此截掉末尾的1只能存52個0

    • 能夠看出來,2^53 和 2^53+1 在計算機中的存儲的分數部分、指數部分都相同,因此兩個不一樣的數在計算機中的存儲是同樣的,當大於這安全值時就可能會出現精度丟失,這樣就很是的不安全了。因此 2^53-1 是 JavaScript 裏面的最大安全整數

  • 既然小數在計算機中會有精度丟失,那爲何 num = 0.1 能獲得 0.1 呢?

    • Number.toPrecision() 跟 toFixed 相似,表示要保留幾位有效數字。前面咱們知道 0.1 在存儲的過程當中實際上是丟失了精度的,由於它在轉換爲 2 進制時爲無限循環,之因此咱們寫 0.1 能獲得 0.1 是由於 js 幫作了處理。
  • const num = 0.1 爲何能獲得 0.1 呢?
    Number.toPrecision() 跟 toFixed 相似,表示要保留幾位有效數字。前面咱們知道 0.1 在存儲的過程當中實際上是丟失了精度的,由於它在轉換爲 2 進制時爲無限循環,之因此咱們寫 0.1 能獲得 0.1 是由於 js 幫咱們作了處理 image.png
    從圖中看到咱們讓 0.1 保留 25 位有效數字,得出來的結果並非 0.1,因此 js 默認幫咱們作了截斷。那麼這個問題就能夠轉化爲:雙精度浮點數是按什麼規則來截斷的呢? 在雙精度浮點數的英文wiki中能夠找到中能夠知道

    • 若是十進制有效數字未超過 15 位,那麼存儲和讀取的時候十進制都同樣,js 不會截斷
    • 若是十進制有效數字至少有 17 位,因爲分數位(presicion)最多隻能存儲 53 位,最終以該 53 位爲準,後面的就會被截斷,截斷後從新計算出來的就是一個截斷後的數字,例如 0.1 跟 0.10000000000000001(17)實際上是相等的,由於後者在存儲時轉換爲雙精度浮點數的形式跟前者的雙精度浮點數的形式是同樣的,因此後者在存儲時存儲的就是 0.1
  • 爲何 例如1.335.toFixed(2) 獲得的是 1.33? image.png

    • 1.335 在咱們存儲爲數字時其實存儲的雙精度浮點數就爲 1.335,以下圖中的雙精度浮點數的表示,雖然在存儲時會把 53 位及後面的截斷,但當把下面的雙精度浮點數換算爲 10 進制,其實獲得的就是 1.335 image.png

    • 爲何咱們調用 toPrecision 還能獲得 1.335 被截斷前的值呢?首先咱們存儲的 1.335 是 Number 格式,Number 格式受最大存儲位數的限制,因此 1.335 會被截斷,可是咱們在調用 toPrecision() 時能夠看到實際上是以字符串的形式表示出來的,字符串無論再長都能表示出來,因此咱們能夠獲得被截斷前的值。當你再將截斷前的值轉換爲 Number 時,因爲受雙精度浮點數存儲位數的限制,存儲時就又會獲得截斷後的值

0.1 + 0.2 !== 0.3 -> true 分析

有了以上鋪墊沒,既然 0.1 + 0.2 !== 0.3,所以不只是 JavaScript 會產生這種問題,只要是採用 IEEE 754 雙精度浮點數編碼方式來表示浮點數的都會產生這類問題。分析過程

// 0.1
e = -4;
m = 1.1001100110011001100110011001100110011001100110011010 (52位)

// 0.2
e = -3;
m = 1.1001100110011001100110011001100110011001100110011010 (52位)
複製代碼

這裏的 m 指的是小數點後的 52 位,而小數點前的整數部分 1 就是前面說過的隱藏位

而後把它們相加,這裏有一個問題就是指數不一致時應該怎麼處理,通常是往右移,由於即便右邊溢出了,損失的精度遠遠小於左移時的溢出

e = -4;
m = 1.1001100110011001100110011001100110011001100110011010 (52位)
+
e = -3; 
m = 1.1001100110011001100110011001100110011001100110011010 (52位)
複製代碼

轉化

e = -3; 
m = 0.1100110011001100110011001100110011001100110011001101 (52位)
+
e = -3; 
m = 1.1001100110011001100110011001100110011001100110011010 (52位)
複製代碼

獲得

e = -3; 
m = 10.0110011001100110011001100110011001100110011001100111 (52位)
複製代碼

保留一位整數

e = -2;
m = 1.00110011001100110011001100110011001100110011001100111 (53位)
複製代碼

此時已經溢出來了(超過了 52 位),那麼這時就要作四捨五入了,那怎麼舍入才能與原來的數最接近呢?好比 1.101 要保留 2 位小數則結果有多是 1.10 和 1.11,這時兩個都是同樣近,取哪個呢?規則是保留偶數的那一個,在這裏就是保留 1.10

回到上面以前,結果就是

m = 1.001100110011001100110011001100110011001100110011010052位)
複製代碼

而後獲得最終的二進制數

2 ^ -2 * 1.0011001100110011001100110011001100110011001100110100 
= 0.010011001100110011001100110011001100110011001100110100
複製代碼

最終轉化爲十進制就是:0.30000000000000004

解決方案

特殊常數 Number.EPSILON

根據規格,Number.EPSILON 表示 1 與大於 1 的最小浮點數之間的差,Number.EPSILON 其實是 JavaScript 可以表示的最小精度,偏差若是小於這個值就能夠認爲已經沒有意義了,即不存在偏差

比較兩個數字與 Number.EPSILON 之間的絕對差值

function numbersEqual(num1, num2) {
    return Math.abs(num1 - num2) < Number.EPSILON
}
const a = 0.1+0.2, b=0.3;
console.log(numbersEqual(a, b)); // true
複製代碼

考慮兼容性問題,在 chrome 中支持這個屬性,但 IE 並不支持(IE10 不兼容),因此還要解決 IE 的不兼容問題

Number.EPSILON=(function(){   //解決兼容性問題
  return Number.EPSILON?Number.EPSILON:Math.pow(2,-52);
})();
//上面是一個自調用函數,當 JS 文件剛加載到內存中就會去判斷並返回一個結果,相比 
//if(!Number.EPSILON){
// Number.EPSILON=Math.pow(2,-52);
//} 
// 這種代碼更節約性能也更美觀
function numbersequal(a,b){ 
  return Math.abs(a-b) < Number.EPSILON;
}
// 接下來再判斷 
const a=0.1+0.2, b=0.3;
console.log(numbersequal(a, b)); // true
複製代碼

toFixed()

  • toFixed(num) 方法可把 Number 四捨五入爲指定小數位數的數字
  • 參數描述:num 必需,規定小數的位數,是 0 ~ 20 之間包括 0 和 20的值,有些實現能夠支持更大的數值範圍,若省略了該參數將用 0 代替
  • 特別注意:toFixed() 返回一個數值的字符串表現形式,該數值在必要時進行四捨五入,另外在必要時會用 0 來填充小數部分,以便小數部分有指定的位數。若數值大於 1e+21,該方法會簡單調用 Number.prototype.toString() 方法並返回一個指數記數法格式的字符串
  • 能夠這樣解決精度問題
parseFloat((數學表達式).toFixed(digits)); // toFixed() 精度參數須在 0 與20 之間
// 運行
parseFloat((0.1 + 0.2).toFixed(10))// 結果爲 0.3
parseFloat((0.3 / 0.1).toFixed(10)) // 結果爲 3 
parseFloat((0.7 * 180).toFixed(10))// 結果爲 126
parseFloat((1.0 - 0.9).toFixed(10)) // 結果爲 0.1 
parseFloat((9.7 * 100).toFixed(10)) // 結果爲 970 
parseFloat((2.22 + 0.1).toFixed(10)) // 結果爲 2.32
複製代碼

原生封裝

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;
}

function roundFractional(x, n) {
  return Math.round(x * Math.pow(10, n)) / Math.pow(10, n);
}
複製代碼

第三方類庫

math.js、D.js、bigNumber.js、decimal.js、big.js 等

相關文章
相關標籤/搜索