作者簡(jiǎn)介:
Jaked
8年前端工作經(jīng)驗(yàn),
主要分享:職業(yè)發(fā)展方面腊凶、前端技術(shù)销斟、面試技巧等。
公眾號(hào):超哥前端小棧
掘金:https://juejin.im/user/5a5d4522518825732b19d364/posts
最近抡四,朋友 L 問了我這樣一個(gè)問題:在 chrome 中的運(yùn)算結(jié)果,為什么是這樣的慎璧?
0.55 * 100 // 55.000000000000010.56 * 100 // 56.000000000000010.57 * 100 // 56.999999999999990.58 * 100 // 57.999999999999990.59 * 100 // 590.60 * 100 // 60
雖然我告訴他說床嫌,這是由于浮點(diǎn)數(shù)精度問題導(dǎo)致的。但他還是不太明白胸私,為何有的結(jié)果輸出整數(shù),有的是以 ...001 的小數(shù)結(jié)尾鳖谈,有的卻是以 ...999 的小數(shù)結(jié)尾岁疼,跟預(yù)想中的有差異。
這其實(shí)牽涉到了計(jì)算機(jī)原理的知識(shí)缆娃,真要解釋清楚什么是浮點(diǎn)數(shù)捷绒,恐怕得分好幾個(gè)章節(jié)了。想深入了解的同學(xué)贯要,可以前往 這篇文章 細(xì)讀暖侨。今天我們僅討論浮點(diǎn)數(shù)運(yùn)算結(jié)果的成因,以及如何實(shí)現(xiàn)我們期望的結(jié)果崇渗。
浮點(diǎn)數(shù)與 IEEE 754
在解釋什么是浮點(diǎn)數(shù)之前字逗,讓我們先從較為簡(jiǎn)單的小數(shù)點(diǎn)說起。
小數(shù)點(diǎn)宅广,在數(shù)制中代表一種對(duì)齊方式葫掉。比如要比較 1000 和 200 哪個(gè)比較大,該怎么做呢跟狱?必須把他們右對(duì)齊:
1000 200
發(fā)現(xiàn) 1 比 0(前面補(bǔ)零)大俭厚,所以 1000 比較大。那么如果要比較 1000 和 200.01 呢驶臊?這時(shí)候就不是右對(duì)齊了挪挤,而應(yīng)該是以小數(shù)點(diǎn)對(duì)齊:
1000 200.01
小數(shù)點(diǎn)的位置,在進(jìn)制表示中是至關(guān)重要的关翎。位置差一位整體就要差進(jìn)制倍(十進(jìn)制就是十倍)扛门。在計(jì)算機(jī)中也是這樣,雖然計(jì)算機(jī)使用二進(jìn)制笤休,但在處理非整數(shù)時(shí)尖飞,也需要考慮小數(shù)點(diǎn)的位置問題。無法對(duì)齊小數(shù)點(diǎn),就無法做加減法比較這樣的操作政基。
接下來的一個(gè)重要概念:在計(jì)算機(jī)中的小數(shù)有兩種贞铣,定點(diǎn) 和 浮點(diǎn)。
定點(diǎn)的意思是沮明,小數(shù)點(diǎn)固定在 32 位中的某個(gè)位置辕坝,前面的是整數(shù),后面的是小數(shù)荐健。小數(shù)點(diǎn)具體固定在哪里酱畅,可以自己在程序中指定。定點(diǎn)數(shù)的優(yōu)點(diǎn)是很簡(jiǎn)單江场,大部分運(yùn)算實(shí)現(xiàn)起來和整數(shù)一樣或者略有變化纺酸,但是缺點(diǎn)則是表示范圍太小,精度很差址否,不能充分運(yùn)用存儲(chǔ)單元餐蔬。
浮點(diǎn)數(shù)就是設(shè)計(jì)來克服這個(gè)缺點(diǎn)的,它相當(dāng)于一個(gè)定點(diǎn)數(shù)加上一個(gè)階碼佑附,階碼表示將這個(gè)定點(diǎn)數(shù)的小數(shù)點(diǎn)移動(dòng)若干位樊诺。由于可以用階碼移動(dòng)小數(shù)點(diǎn),因此稱為浮點(diǎn)數(shù)音同。我們?cè)趯懗绦驎r(shí)词爬,用到小數(shù)的地方,用 float 類型表示权均,可以方便快速地對(duì)小數(shù)進(jìn)行運(yùn)算顿膨。
浮點(diǎn)數(shù)在 Javascript 中的存儲(chǔ),與其他語言如 Java 和 Python 不同螺句。所有數(shù)字(包括整數(shù)和小數(shù))都只有一種類型 — Number虽惭。它的實(shí)現(xiàn)遵循 IEEE 754 標(biāo)準(zhǔn),使用64位精度來表示浮點(diǎn)數(shù)蛇尚。它是目前最廣泛使用的格式芽唇,該格式用 64 位二進(jìn)制表示像下面這樣從上圖中可以看出,這 64 位分為三個(gè)部分:
符號(hào)位:1 位用于標(biāo)志位取劫。用來表示一個(gè)數(shù)是正數(shù)還是負(fù)數(shù)
指數(shù)位:11 位用于指數(shù)匆笤。這允許指數(shù)最大到 1024
尾數(shù)位:剩下的 52 位代表的是尾數(shù),超出的部分自動(dòng)進(jìn)一舍零
精度丟哪兒去了谱邪?
問:要把小數(shù)裝入計(jì)算機(jī)炮捧,總共分幾步?
答:3 步惦银。
第一步:轉(zhuǎn)換成二進(jìn)制
第二步:用二進(jìn)制科學(xué)計(jì)算法表示
第三步:表示成 IEEE 754 形式
但第一步和第三步都有可能 丟失精度咆课。
十進(jìn)制是給人看的末誓。但在進(jìn)行運(yùn)算之前,必須先轉(zhuǎn)換為計(jì)算機(jī)能處理的二進(jìn)制书蚪。最后喇澡,當(dāng)運(yùn)算完畢后,再將結(jié)果轉(zhuǎn)換回十進(jìn)制殊校,繼續(xù)給人看晴玖。精度就丟失于這兩次轉(zhuǎn)換的過程中。
十進(jìn)制轉(zhuǎn)二進(jìn)制
接下來为流,就具體說說轉(zhuǎn)換的過程呕屎。來看一個(gè)簡(jiǎn)單的例子:
如何將十進(jìn)制的 168.45 轉(zhuǎn)換為二進(jìn)制?
讓我們拆為兩個(gè)部分來解析:
1敬察、整數(shù)部分秀睛。它的轉(zhuǎn)換方法是,除 2 取余法莲祸。即每次將整數(shù)部分除以 2琅催,余數(shù)為該位權(quán)上的數(shù),而商繼續(xù)除以 2虫给,余數(shù)又為上一個(gè)位權(quán)上的數(shù),這個(gè)步驟一直持續(xù)下去侠碧,直到商為 0 為止抹估,最后讀數(shù)時(shí)候,從最后一個(gè)余數(shù)讀起弄兜,一直到最前面的一個(gè)余數(shù)药蜻。
所以整數(shù)部分 168 的轉(zhuǎn)換過程如下:
第一步,將 168 除以 2替饿,商 84语泽,余數(shù)為 0。
第二步视卢,將商 84 除以 2踱卵,商 42 余數(shù)為 0。
第三步据过,將商 42 除以 2惋砂,商 21 余數(shù)為 0。
第四步绳锅,將商 21 除以 2西饵,商 10 余數(shù)為 1。
第五步鳞芙,將商 10 除以 2眷柔,商 5 余數(shù)為 0期虾。
第六步,將商 5 除以 2驯嘱,商 2 余數(shù)為 1镶苞。
第七步,將商 2 除以 2宙拉,商 1 余數(shù)為 0宾尚。
第八步,將商 1 除以 2谢澈,商 0 余數(shù)為 1煌贴。
第九步,讀數(shù)锥忿。因?yàn)樽詈笠晃皇墙?jīng)過多次除以 2 才得到的牛郑,因此它是最高位。讀數(shù)的時(shí)候敬鬓,從最后的余數(shù)向前讀淹朋,即 10101000。
2钉答、小數(shù)部分础芍。它的轉(zhuǎn)換方法是,乘 2 取整法数尿。即將小數(shù)部分乘以 2仑性,然后取整數(shù)部分,剩下的小數(shù)部分繼續(xù)乘以 2右蹦,然后再取整數(shù)部分诊杆,剩下的小數(shù)部分又乘以 2,一直取到小數(shù)部分為 0 為止何陆。如果永遠(yuǎn)不能為零晨汹,就同十進(jìn)制數(shù)的四舍五入一樣,按照要求保留多少位小數(shù)時(shí)贷盲,就根據(jù)后面一位是 0 還是 1 進(jìn)行取舍淘这。如果是 0 就舍掉,如果是 1 則入一位晃洒,換句話說就是慨灭,0 舍 1 入。讀數(shù)的時(shí)候球及,要從前面的整數(shù)開始氧骤,讀到后面的整數(shù)。
所以小數(shù)部分 0.45 (保留到小數(shù)點(diǎn)第四位)的轉(zhuǎn)換過程如下:
第一步吃引,將 0.45 乘以 2筹陵,得 0.9刽锤,則整數(shù)部分為 0,小數(shù)部分為 0.9朦佩。
第二步, 將小數(shù)部分 0.9 乘以 2并思,得 1.8,則整數(shù)部分為 1语稠,小數(shù)部分為 0.8宋彼。
第三步, 將小數(shù)部分 0.8 乘以 2,得 1.6仙畦,則整數(shù)部分為 1输涕,小數(shù)部分為 0.6。
第四步慨畸,將小數(shù)部分 0.6 乘以 2莱坎,得 1.2,則整數(shù)部分為 1寸士,小數(shù)部分為 0.2檐什。
第五步,將小數(shù)部分 0.2 乘以 2弱卡,得 0.4乃正,則整數(shù)部分為 0,小數(shù)部分為 0.4婶博。
第六步烫葬,將小數(shù)部分 0.4 乘以 2,得 0.8凡蜻,則整數(shù)部分為 0,小數(shù)部分為 0.8垢箕。
...
可以看到划栓,從第六步開始,將無限循環(huán)第三条获、四忠荞、五步,一直乘下去帅掘,最后不可能得到小數(shù)部分為 0委煤。因此,這個(gè)時(shí)候只好學(xué)習(xí)十進(jìn)制的方法進(jìn)行四舍五入了修档。但是二進(jìn)制只有 0 和 1 兩個(gè)碧绞,于是就出現(xiàn) 0 舍 1 入的 “口訣” 了,這也是計(jì)算機(jī)在轉(zhuǎn)換中會(huì)產(chǎn)生誤差的根本原因吱窝。但是由于保留位數(shù)很多讥邻,精度很高迫靖,所以可以忽略不計(jì)。
這樣兴使,我們就可以得出十進(jìn)制數(shù) 168.45 轉(zhuǎn)換為二進(jìn)制的結(jié)果系宜,約等于 10101000.0111。
二進(jìn)制轉(zhuǎn)十進(jìn)制
它的轉(zhuǎn)換方法相對(duì)簡(jiǎn)單些发魄,按權(quán)相加法盹牧。就是將二進(jìn)制每位上的數(shù)乘以權(quán),然后相加之和即是十進(jìn)制數(shù)励幼。其中有兩個(gè)注意點(diǎn):要知道二進(jìn)制每位的權(quán)值汰寓,要能求出每位的值。
所以赏淌,將剛才的二進(jìn)制 10101000.0111 轉(zhuǎn)換為十進(jìn)制踩寇,得到的結(jié)果就是 168.4375,再四舍五入一下六水,即 168.45俺孙。
解決方案
正如本文開頭所提到的,在 JavaScript 中進(jìn)行浮點(diǎn)數(shù)的運(yùn)算掷贾,會(huì)有不少奇葩的問題睛榄。在明白了產(chǎn)生問題的根本原因之后,當(dāng)然是想辦法解決啦~
一個(gè)簡(jiǎn)單粗暴的建議是想帅,使用像 mathjs 這樣的庫场靴。它的 API 也挺簡(jiǎn)單的:
// load math.jsconst math = require('mathjs')// functions and constantsmath.round(math.e, 3) // 2.718math.atan2(3, -3) / math.pi // 0.75// expressionsmath.eval('12 / (2.3 + 0.7)') // 4math.eval('12.7 cm to inch') // 5 inchmath.eval('sin(45 deg) ^ 2') // 0.5// chainingmath.chain(3) .add(4) .multiply(2) .done() // 14
但如果在工程中,沒有太多需要進(jìn)行運(yùn)算的場(chǎng)景的話港准,就不建議這么做了旨剥。畢竟引入三方庫也是有成本的,無論是學(xué)習(xí) API浅缸,還是引入庫之后轨帜,帶來打包后的文件體積增積。
那么衩椒,不引入庫該怎么處理浮點(diǎn)數(shù)呢蚌父?
可以從需求出發(fā)。例如毛萌,本文開頭的例子苟弛。可以猜想到阁将,需求可能是要把小數(shù)轉(zhuǎn)為百分比膏秫,通常會(huì)保留兩位小數(shù)。而在一些對(duì)數(shù)字較為敏感的業(yè)務(wù)場(chǎng)景中做盅,可能并不希望對(duì)數(shù)字進(jìn)行四舍五入荔睹,所以 toFixed() 方法就沒法用了狸演。
一種思路是,將小數(shù)點(diǎn)像右多移動(dòng) n 位僻他,取整后再除以 (10 * n)宵距。比如這樣:
0.58 * 10000 / 100 // => 58
ok,搞定~
特別需要注意的是吨拗,在需要四舍五入的場(chǎng)景下满哪,我們會(huì)習(xí)慣用到內(nèi)置方法 toFixed()
,但它存在一些問題:
1.35.toFixed(1) // 1.4 正確1.335.toFixed(2) // 1.33 錯(cuò)誤1.3335.toFixed(3) // 1.333 錯(cuò)誤1.33335.toFixed(4) // 1.3334 正確1.333335.toFixed(5) // 1.33333 錯(cuò)誤1.3333335.toFixed(6) // 1.333333 錯(cuò)誤
另外劝篷,它的返回結(jié)果類型是 String
哨鸭。不能直接拿來做運(yùn)算,因?yàn)橛?jì)算機(jī)會(huì)認(rèn)為是 字符串拼接
娇妓。
總結(jié)
計(jì)算機(jī)在做運(yùn)算的時(shí)候像鸡,會(huì)分三個(gè)步驟。其中哈恰,將十進(jìn)制轉(zhuǎn)為二進(jìn)制只估,再將二進(jìn)制轉(zhuǎn)為十進(jìn)制的時(shí)候,都會(huì)產(chǎn)生精度丟失着绷。
使用庫蛔钙,是最簡(jiǎn)單粗暴的解決方案。但如果使用不頻繁荠医,還是要根據(jù)需求吁脱,手動(dòng)解決。在使用內(nèi)置方法 toFixed() 的時(shí)候彬向,要特別注意它的返回類型兼贡,不要直接拿來做運(yùn)算。
作者簡(jiǎn)介:
Jaked
8年前端工作經(jīng)驗(yàn)娃胆,
主要分享:職業(yè)發(fā)展方面紧显、前端技術(shù)、面試技巧等缕棵。
公眾號(hào):超哥前端小棧
掘金:https://juejin.im/user/5a5d4522518825732b19d364/posts
本文已經(jīng)獲得Jaked老師授權(quán)轉(zhuǎn)發(fā),其他人若有興趣轉(zhuǎn)載涉兽,請(qǐng)直接聯(lián)系作者授權(quán)招驴。