由於計算機是用二進制來存儲和處理數字,不能精確表示浮點數,而JavaScript中沒有相應的封裝類來處理浮點數運算,直接計算會導致運算精度丟失。
為了避免產生精度差異,把需要計算的數字升級(乘以10的n次冪)成計算機能夠精確識別的整數,等計算完畢再降級(除以10的n次冪),這是大部分編程語言處理精度差異的通用方法。
關鍵詞:
計算精度 四舍五入 四則運算 精度丟失
1. 疑惑
我們知道,幾乎每種編程語言都提供了適合貨幣計算的類。例如C#提供了decimal,Java提供了BigDecimal,JavaScript提供了Number……
由於之前用decimal和BigDecimal用得很好,沒有產生過精度問題,所以一直沒有懷疑過JavaScript的Number類型,以為可以直接使用Number類型進行計算。但是直接使用是有問題的。
我們先看看四舍五入的如下代碼:
- alert(Number(0.009).toFixed(2));
- alert(Number(162.295).toFixed(2));
按正常結果,應該分別彈出0.01和162.30。但實際測試結果卻是在不同瀏覽器中得到的是不同的結果:
在ie6、7、8下得到0.00和162.30,第一個數截取不正確;
在firefox中得到0.01和162.29,第二個數截取不正確;
在opera下得到0.01和162.29,第二個數截取不正確
我們再來看看四則運算的代碼:
- alert(1/3);//彈出: 0.3333333333333333
- alert(0.1 + 0.2);//彈出: 0.30000000000000004
- alert(-0.09 - 0.01);//彈出: -0.09999999999999999
- alert(0.012345 * 0.000001);//彈出: 1.2344999999999999e-8
- alert(0.000001 / 0.0001);//彈出: 0.009999999999999998
按正常結果,除第一行外(因為其本身就不能除盡),其他都應該要得到精確的結果,從彈出的結果我們卻發現不是我們想要的正確結果。是因為沒有轉換成Number類型嗎?我們轉換成Number后再計算看看:
- alert(Number(1)/Number(3));//彈出: 0.3333333333333333
- alert(Number(0.1) + Number(0.2));//彈出: 0.30000000000000004
- alert(Number(-0.09) – Number(0.01));//彈出: -0.09999999999999999
- alert(Number(0.012345) * Number(0.000001));//彈出: 1.2344999999999999e-8
- alert(Number(0.000001) / Number(0.0001));//彈出: 0.009999999999999998
還是一樣的結果,看來javascript默認把數字識別為number類型。為了驗證這一點,我們用typeof彈出類型看看:
- alert(typeof(1));//彈出: number
- alert(typeof(1/3));//彈出: number
- alert(typeof(-0.09999999));//彈出: number
2. 原因
為什么會產生這種精度丟失的問題呢?是javascript語言的bug嗎?
我們回憶一下大學時學過的計算機原理,計算機執行的是二進制算術,當十進制數不能准確轉換為二進制數時,這種精度誤差就在所難免。
再查查javascript的相關資料,我們知道javascript中的數字都是用浮點數表示的,並規定使用IEEE 754 標准的雙精度浮點數表示:
IEEE 754 規定了兩種基本浮點格式:單精度和雙精度。
IEEE單精度格式具有24 位有效數字精度(包含符號號),並總共占用32 位。
IEEE雙精度格式具有53 位有效數字精度(包含符號號),並總共占用64 位。
這種結構是一種科學表示法,用符號(正或負)、指數和尾數來表示,底數被確定為2,也就是說是把一個浮點數表示為尾數乘以2的指數次方再加上符號。下面來看一下具體的規格:
符號位 | 指數位 | 小數部分 | 指數偏移量 | |
單精度浮點數 | 1位(31) | 8位(30-23) | 23位(22-00) | 127 |
雙精度浮點數 | 1位(63) | 11位(62-52) | 52位(51-00) | 1023 |
我們以單精度浮點數來說明:
指數是8位,可表達的范圍是0到255
而對應的實際的指數是-127到+128
這里特殊說明,-127和+128這兩個數據在IEEE當中是保留的用作多種用途的
-127表示的數字是0
128和其他位數組合表示多種意義,最典型的就是NAN狀態。
知道了這些,我們來模擬計算機的進制轉換的計算,就找一個簡單的0.1+0.2來推演吧(引用自http://blog.csdn.net/xujiaxuliang/archive/2010/10/13/5939573.aspx):
- 十進制0.1
- => 二進制0.00011001100110011…(循環0011)
- =>尾數為1.1001100110011001100…1100(共52位,除了小數點左邊的1),指數為-4(二進制移碼為00000000010),符號位為0
- => 計算機存儲為:0 00000000100 10011001100110011…11001
- => 因為尾數最多52位,所以實際存儲的值為0.00011001100110011001100110011001100110011001100110011001
- 而十進制0.2
- => 二進制0.0011001100110011…(循環0011)
- =>尾數為1.1001100110011001100…1100(共52位,除了小數點左邊的1),指數為-3(二進制移碼為00000000011),符號位為0
- => 存儲為:0 00000000011 10011001100110011…11001
- 因為尾數最多52位,所以實際存儲的值為0.00110011001100110011001100110011001100110011001100110011
- 那么兩者相加得:
- 0.00011001100110011001100110011001100110011001100110011001
- + 0.00110011001100110011001100110011001100110011001100110011
- = 0.01001100110011001100110011001100110011001100110011001100
- 轉換成10進制之后得到:0.30000000000000004
從上述的推演過程我們知道,這種誤差是難免的,c#的decimal和Java的BigDecimal之所以沒有出現精度差異,只是因為在其內部作了相應處理,把這種精度差異給屏蔽掉了,而javascript是一種弱類型的腳本語言,本身並沒有對計算精度做相應的處理,這就需要我們另外想辦法處理了。
3. 解決辦法
3.1 升級降級
從上文我們已經知道,javascript中產生精度差異的原因是計算機無法精確表示浮點數,連自身都不能精確,運算起來就更加得不到精確的結果了。那么怎么讓計算機精確認識要計算的數呢?
我們知道十進制的整數和二進制是可以互相進行精確轉換的,那么我們把浮點數升級(乘以10的n次冪)成計算機能夠精確識別的整數來計算,計算完畢之后再降級(除以10的n次冪),不就得到精確的結果了嗎?好,就這么辦!
我們知道,Math.pow(10,scale)可以得到10的scale次方,那么就把浮點數直接乘以Math.pow(10,scale)就可以了嗎?我最初就是這么想的,但后來卻發現一些數字運算后實際結果與我們的猜想並不一致。我們來看看這個簡單的運算:
- alert(512.06*100);
按常理應該返回51206,但實際結果卻是51205.99999999999。奇怪吧?其實也不奇怪,這是因為浮點數不能精確參與乘法運算,即使這個運算很特殊(只是乘以10的scale次方進行升級)。如此我們就不能直接乘以10的scale次方進行升級,那就讓我們自己來挪動小數點吧。
怎么挪動小數點肯定大家是各有妙招,此處附上我寫的幾個方法:
- /**
- * 左補齊字符串
- *
- * @param nSize
- * 要補齊的長度
- * @param ch
- * 要補齊的字符
- * @return
- */
- String.prototype.padLeft = function(nSize, ch)
- {
- var len = 0;
- var s = this ? this : "";
- ch = ch ? ch : '0';// 默認補0
- len = s.length;
- while (len < nSize)
- {
- s = ch + s;
- len++;
- }
- return s;
- }
- /**
- * 右補齊字符串
- *
- * @param nSize
- * 要補齊的長度
- * @param ch
- * 要補齊的字符
- * @return
- */
- String.prototype.padRight = function(nSize, ch)
- {
- var len = 0;
- var s = this ? this : "";
- ch = ch ? ch : '0';// 默認補0
- len = s.length;
- while (len < nSize)
- {
- s = s + ch;
- len++;
- }
- return s;
- }
- /**
- * 左移小數點位置(用於數學計算,相當於除以Math.pow(10,scale))
- *
- * @param scale
- * 要移位的刻度
- * @return
- */
- String.prototype.movePointLeft = function(scale)
- {
- var s, s1, s2, ch, ps, sign;
- ch = '.';
- sign = '';
- s = this ? this : "";
- if (scale <= 0) return s;
- ps = s.split('.');
- s1 = ps[0] ? ps[0] : "";
- s2 = ps[1] ? ps[1] : "";
- if (s1.slice(0, 1) == '-')
- {
- s1 = s1.slice(1);
- sign = '-';
- }
- if (s1.length <= scale)
- {
- ch = "0.";
- s1 = s1.padLeft(scale);
- }
- return sign + s1.slice(0, -scale) + ch + s1.slice(-scale) + s2;
- }
- /**
- * 右移小數點位置(用於數學計算,相當於乘以Math.pow(10,scale))
- *
- * @param scale
- * 要移位的刻度
- * @return
- */
- String.prototype.movePointRight = function(scale)
- {
- var s, s1, s2, ch, ps;
- ch = '.';
- s = this ? this : "";
- if (scale <= 0) return s;
- ps = s.split('.');
- s1 = ps[0] ? ps[0] : "";
- s2 = ps[1] ? ps[1] : "";
- if (s2.length <= scale)
- {
- ch = '';
- s2 = s2.padRight(scale);
- }
- return s1 + s2.slice(0, scale) + ch + s2.slice(scale, s2.length);
- }
- /**
- * 移動小數點位置(用於數學計算,相當於(乘以/除以)Math.pow(10,scale))
- *
- * @param scale
- * 要移位的刻度(正數表示向右移;負數表示向左移動;0返回原值)
- * @return
- */
- String.prototype.movePoint = function(scale)
- {
- if (scale >= 0)
- return this.movePointRight(scale);
- else
- return this.movePointLeft(-scale);
- }
這樣我們升級降級都可以轉換成字符串后調用String對象的自定義方法movePoint了,乘以10的scale次方我們傳正整數scale,除以10的scale次方我們傳負整數-scale。
再來看看我們之前升級512.06的代碼,采用自定義方法的調用代碼變成這樣:
- alert(512.06.toString().movePoint(2)); //彈出: 51206
這樣直接挪動小數點就不怕它不聽話出現一長串數字了(*^__^*)。 當然,movePoint方法得到的結果是字符串,如果要轉成Number類型也很方便(怎么轉就不再廢話了)。
3.2 四舍五入
好,有了升級降級的基礎,我們來看看四舍五入的方法,由於不同瀏覽器對Number的toFixed方法有不同的支持,我們需要用自己的方法去覆蓋瀏覽器的默認實現。
有一個簡單的辦法是我們自己來判斷要截取數據的后一位是否大於等於5,然后進行舍或者入。我們知道Math.ceil方法是取大於等於指定數的最小整數,Math.floor方法是取小於等於指定數的最大整數,於是我們可以利用這兩個方法來進行舍入處理,先將要進行舍入的數升級要舍入的位數scale(乘以10的scale次方),進行ceil或floor取整后,再降級要舍入的位數scale(除以10的scale次方)。
代碼如下:
- Number.prototype.toFixed = function(scale)
- {
- var s, s1, s2, start;
- s1 = this + "";
- start = s1.indexOf(".");
- s = s1.movePoint(scale);
- if (start >= 0)
- {
- s2 = Number(s1.substr(start + scale + 1, 1));
- if (s2 >= 5 && this >= 0 || s2 < 5 && this < 0)
- {
- s = Math.ceil(s);
- }
- else
- {
- s = Math.floor(s);
- }
- }
- return s.toString().movePoint(-scale);
- }
覆蓋Number類型的toFixed方法后,我們再來執行以下方法
- alert(Number(0.009).toFixed(2));//彈出0.01
- alert(Number(162.295).toFixed(2));//彈出162.30
在ie6、7、8、firefox、Opera下分別進行驗證,都能得到相應的正確的結果。
另一種方式是在網上找到的采用正則表達式來進行四舍五入,代碼如下:
- Number.prototype.toFixed = function(scale)
- {
- var s = this + "";
- if (!scale) scale = 0;
- if (s.indexOf(".") == -1) s += ".";
- s += new Array(scale + 1).join("0");
- if (new RegExp("^(-|\\+)?(\\d+(\\.\\d{0," + (scale + 1) + "})?)\\d*$").test(s))
- {
- var s = "0" + RegExp.$2, pm = RegExp.$1, a = RegExp.$3.length, b = true;
- if (a == scale + 2)
- {
- a = s.match(/\d/g);
- if (parseInt(a[a.length - 1]) > 4)
- {
- for (var i = a.length - 2; i >= 0; i--)
- {
- a[i] = parseInt(a[i]) + 1;
- if (a[i] == 10)
- {
- a[i] = 0;
- b = i != 1;
- }
- else
- break;
- }
- }
- s = a.join("").replace(new RegExp("(\\d+)(\\d{" + scale + "})\\d$"), "$1.$2");
- }
- if (b) s = s.substr(1);
- return (pm + s).replace(/\.$/, "");
- }
- return this + "";
- }
經驗證,這兩個方法都能夠進行准確的四舍五入,那么采用哪個方法好呢?實踐出真知,我們寫一個簡單的方法來驗證一下兩種方式的性能:
- function testRound()
- {
- var dt, dtBegin, dtEnd, i;
- dtBegin = new Date();
- for (i=0; i<100000; i++)
- {
- dt = new Date();
- Number("0." + dt.getMilliseconds()).toFixed(2);
- }
- dtEnd = new Date();
- alert(dtEnd.getTime()-dtBegin.getTime());
- }
為了避免對同一個數字進行四舍五入運算有緩存問題,我們取當前毫秒數進行四舍五入。經驗證,在同一台機器上運算10萬次的情況下,用movePoint方法,平均耗時2500毫秒;用正則表達式方法,平均耗時4000毫秒。
3.3 加減乘除
對指定數字進行四舍五入可以通過floor/ceil或者正則表達式達到舍入的目的,那么四則運算是不是也可以升級成計算機能夠精確識別的整數來計算,計算完畢再降級呢?這個答案是肯定的,我們先來看看加法:
- Number.prototype.add = function(arg)
- {
- var n, n1, n2, s, s1, s2, ps;
- s1 = this.toString();
- ps = s1.split('.');
- n1 = ps[1] ? ps[1].length : 0;
- s2 = arg.toString();
- ps = s2.split('.');
- n2 = ps[1] ? ps[1].length : 0;
- n = n1 > n2 ? n1 : n2;
- s = Number(s1.movePoint(n)) + Number(s2.movePoint(n));
- s = s.toString().movePoint(-n);
- return Number(s);
- }
這時候再執行之前的加法
alert(Number(0.1).add(0.2));//彈出0.3
這時候就可以計算出精確的結果了。
類似可以寫出減法:
- Number.prototype.sub = function(arg)
- {
- var n, n1, n2, s, s1, s2, ps;
- s1 = this.toString();
- ps = s1.split('.');
- n1 = ps[1] ? ps[1].length : 0;
- s2 = arg.toString();
- ps = s2.split('.');
- n2 = ps[1] ? ps[1].length : 0;
- n = n1 > n2 ? n1 : n2;
- s = Number(s1.movePoint(n)) - Number(s2.movePoint(n));
- s = s.toString().movePoint(-n);
- return Number(s);
- }
類似可以寫出乘法:
- Number.prototype.mul = function(arg)
- {
- var n, n1, n2, s, s1, s2, ps;
- s1 = this.toString();
- ps = s1.split('.');
- n1 = ps[1] ? ps[1].length : 0;
- s2 = arg.toString();
- ps = s2.split('.');
- n2 = ps[1] ? ps[1].length : 0;
- n = n1 + n2;
- s = Number(s1.replace('.', '')) * Number(s2.replace('.', ''));
- s = s.toString().movePoint(-n);
- return Number(s);
- }
類似可以寫出除法:
- Number.prototype.div = function(arg)
- {
- var n, n1, n2, s, s1, s2, ps;
- s1 = this.toString();
- ps = s1.split('.');
- n1 = ps[1] ? ps[1].length : 0;
- s2 = arg.toString();
- ps = s2.split('.');
- n2 = ps[1] ? ps[1].length : 0;
- n = n1 - n2;
- s = Number(s1.replace('.', '')) / Number(s2.replace('.', ''));
- s = s.toString().movePoint(-n);
- return Number(s);
- }
重要提示:由於除法不能精確到幾位小數,在計算完成的時候應根據需要進行適當的四舍五入,以避免產生精度差異。
4. 結論
由於計算機是用二進制來存儲和處理數字,不能精確表示浮點數,因此這種精度差異幾乎出現在所有的編程語言中(例如C/C++/C#,Java),准確的說:“使用了IEEE 754浮點數格式”來存儲浮點類型(float 32,double 64)的任何編程語言都有這個問題!而C#、Java是因為提供了封裝類decimal、BigDecimal來進行相應的處理才避開了這個精度差異。
為了避免產生精度差異,把需要計算的數字全升級(乘以10的n次冪)成計算機能夠精確識別的整數,等計算完畢再降級(除以10的n次冪),這是大部分編程語言處理精度差異的通用方法。