「硬核JS」數字之美

語言: CN / TW / HK

寫在前面

一直都在佛系更新,這次佛系時間有點長,很久沒發文了,有很多小夥伴滴我,其實由於換工作以及搬家的原因,節奏以及時間上都在調整,甚至還有那麼一小段時間有點焦慮,你懂的,現已逐漸穩定,接下來頻率應該就會高了,奧利給~

可能大家對一些看了能立即上手或者是面經類文章的更為傾向一些,説實話,你可能能瞞過面試官,終究瞞不過自己,應牢記 技術!=面試題 ,應該有很多人會忽略一些基礎的東西吧,殊不知決定樓有多高的是地基

前幾天有朋友問我位運算相關的東西,其實本來是打算寫篇位運算的文章,但描述位運算的前提是需要大家能夠清晰的瞭解計算機中的 數字,數字和位運算又是不同的兩個點,所以直接淦位運算可能並不太好,就拿出了此文修補一番發一下,也算是來補一補之前寫一半就罷工的文章,隨後再補發一篇位運算的文章

數字,很普通的東西,所有語言都有數字,本文的大部分知識點並不僅僅適用於 JavaScript ,其他語言也都類似,數字大家表面看來可能很簡單,其實從計算機到語言本身對數字的處理還是比較複雜的,望本文能夠體現出數字的精妙,故而取名 數字之美

二進制

對於計算機只能存儲二進制,想必是大家耳熟能詳的知識了

我們都知道在計算機內部數據的存儲和運算都採用二進制,是因為計算機是由很多晶體管組成的,而晶體管只有2種狀態,恰好可以用二進制的 0 和 1 表示,並且採用二進制可以使得計算機內部的運算規則簡單,穩定性高,但是由於我們平常並不直接使用二進制,所以可能有小夥伴能給十進制轉二進制都忘了,這裏就簡單介紹一下,當作回顧

整數轉二進制

關於十進制整數轉二進制,其實很簡單,記住一個祕訣,就可以了

2 取餘,逆序排列
複製代碼

就是用 2 整除十進制數,得到商和餘數,再用 2 整除商,得到新的商和餘數,一直重複直至商等於 0,將先得到的餘數作為二進制數的高位,後得到的餘數作為二進制數的低位,依次排序即可

例如,我們將十進制 57 轉換為 2 進制

55 % 2 // 商 27 餘 1
27 % 2 // 商 13 餘 1
13 % 2 // 商  6 餘 1
6  % 2 // 商  3 餘 0
3  % 2 // 商  1 餘 1
1  % 2 // 商  0 餘 1
複製代碼

取餘逆序,那麼十進制 57 轉 2 進制的結果就是 110111

二進制一個數值是 1 位,也就是 1 比特(bit),那麼如果我們需要得到 8 位二進制,那就在轉換結果前補 0 即可

如十進制 57 的 8 位二進制即 00110111,那麼可能還會有人為如果是 4 位怎麼辦呢,4 位是存不了 57 這麼大值的,溢出了

小數轉二進制

可能還有人不瞭解十進制小數是怎麼轉二進制的,其實也有方法口訣

2 取整,順序排列
複製代碼

用 2 乘十進制小數,可以得到積,將積的整數部分取出,再用 2 乘餘下的小數部分,又得到一個積,再將積的整數部分取出,如此進行,直到積中的整數部分為零,或者整數部分為1,此時 0 或 1 為二進制的最後一位或者達到所要求的精度為止,然後把取出的整數部分按順序排列起來,先取的整數作為二進制小數的高位有效位,後取的整數作為低位有效位

例如,將十進制小數 0.625 轉二進制

0.625 * 2 = 1.250  // 取整數 1
0.25  * 2 = 0.50   // 取整數 0
0.5   * 2 = 1	   // 取整數 1 並結束
複製代碼

取整順序,那麼十進制小數 0.625 的二進制即為 0.101

如果該十進制值是一個大於 1 的小數,那麼整數部分和小數部分分別取二進制再拼接即可

例如,將十進制小數 5.125 轉二進制

我們先計算整數 5 的二進制

5 % 2 	// 商  2 餘 1
2 % 2 	// 商  1 餘 1
1 % 2 	// 商  0 餘 1
複製代碼

那麼 5 的二進制即 111,再來看小數部分

0.125 * 2 = 0.250 	// 取整數 0
0.25  * 2 = 0.50  	// 取整數 0
0.5   * 2 = 1		// 取整數 1 並結束
複製代碼

那麼小數部分 0.125 的二進制即 001,拼接可得出十進制數字 5.125 的二進制為 111.001

還會有一種情況,例如十進制小數 0.1 取其二進制

0.1 * 2 = 0.2 	// 取整數 0
0.2 * 2 = 0.4 	// 取整數 0
0.4 * 2 = 0.8 	// 取整數 0
0.8 * 2 = 1.6 	// 取整數 1
0.6 * 2 = 1.2 	// 取整數 1 -> 到此我們看到開始無限循環了
0.2 * 2 = 0.4 	// 取整數 0
0.4 * 2 = 0.8 	// 取整數 0
...
複製代碼

那麼它的二進制就是 0.0001100...... 這樣反覆循環,這也引出了我們在語言層面的問題,例如 JS 中被人詬病的 0.1 + 0.2 != 0.3 的問題,我們後面再説

原碼、反碼和補碼

再説 JS 中的數字問題前,我們還需要補充瞭解下原碼、反碼和補碼的概念,這裏暫先不説結論,我們一步一步的來看,最後在總結什麼是原碼、反碼和補碼

起源

計算機裏保存的是最原始的數字,也就是沒有正和負的數字,我們稱之為無符號數字

假如我們在內存中用 4 位(也就是4bit)去存放表示無符號數字,是下面這樣子的

PS: 這裏也説了是假如,當然你也可以用 32 位來理解,這裏只是為了解釋原碼、反碼、補碼的概念,多少位只有一個區別,那就是可存儲的值範圍大小不同,可存儲位數越大,可以存儲的值範圍就越大,這點後面會説到,這都不重要,主要是 32 位畫圖太累。。。

我們可能注意到了,這樣好像沒辦法表達負數

So,為了表示正與負,先輩們就發明了 原碼,把左邊第一位騰出來,存放符號,正數用 0 來表示,負用 1 來表示

上圖就是正負數的 原碼,你可能在疑惑為什麼上面表裏我只畫到了數字 7,上面也説了,我們這裏使用的示例是 4 位(bit)的存儲方式,只存 4 位,還有一位是符號位,十進制 7 的二進制表達方式就是 0111 了,數字 8 二進制算上符號為是 11000,這就 5 位了,就不是 4 位二進制能存下的了,所以,在只有 4 位存儲二進制時,原碼的取值範圍只有 -7 ~ +7

原碼 這種方式對人來説是很好理解的,但是機器不瞭解啊,表達值沒問題,但是正負相加怎麼加呢?

假如我們要用 (+1) + (-1) ,這個我們上過小學就知道等於 0,但是按照計算機的二進制計算方式,0001 + 1001 = 1010 ,我們對比下原碼錶,也就是 -2

很明顯,這樣計算是不對的,還有就是我們會看到,原碼中的 0 有兩種表示:+0 和 -0,這明顯也是不對的

為了解決正負相加等於 0 的問題,先輩們又在 原碼 的基礎上發明了 反碼

正數的反碼還是等同於原碼,反碼 的表示方式其實就是用來處理負數的,也就是除符號位不變,其餘位置皆取反存儲,0 就存 1,1 就存 0

那麼我們再來看

同上,4 位反碼的值存儲範圍也是 -7 ~ +7

原碼 變成了 反碼 ,我們看之前的(+1)和(-1)相加,變成了 0001 + 1110 = 1111,相加結果對比反碼錶, 1111 也就是 -0 ,就完美的解決了正負相加等於 0 的問題

但是,如果使用 反碼 存儲數值,還是存在那個問題,即 (+0)和(-0)這兩個相同的值,存在兩個不同的二進制表達方式

於是先輩們為了解決這個問題,又提出了 補碼 的概念,也是針對 負數 來做處理的,即從原來 反碼 的基礎上,補充一個新的代碼 1

如上圖所示,處理 反碼 中的 -0 時,給 1111 再補上一個 1 之後,就變成了 10000,由於我們是 4 位存儲,所以要丟掉除符號位的最左側高位,也就是進位中的那一位,也就變成了 0000,剛好和左邊正數的 0 相等

完美解決了(+0)和(-0)同時存在的問題

我們看補碼錶中由於 -0 的補碼是 0000 等同於 +0,因為它補了 1嘛,我們發現 -0 就沒有了意義,所以去掉了 -0 這個數字

我們再看負 7 的補碼也就是反碼加了 1 後的二進制表達方式為 1001 ,以 4 位存儲的方式我們發現補碼錶 1001 還可以再小一位,也就是 1000 即 -8,如下圖

於是補碼的最後補上了一個 -8,也就是在 4 位存儲中補碼的值表達範圍是 -8 ~ +7

同時,我們在使用 補碼 時,正負相加等於 0 的問題也同樣可以解決

例:

我們把(+4)和(-4)相加,0100 + 1100 =10000,有進位,把最高位丟掉,也就是 0000(0)

接下來我們就可以梳理總結下什麼是原碼、反碼、補碼了

原碼

原碼其實就是數值前面增加了一位符號位(即最高位為符號位),正數時符號位為 0

負數時符號位為 1(0有兩種表示:+0 和 -0),其餘位表示數值的大小

例:

我們這次使用 8 位(bit)二進制表示一個數,那麼正 5 的原碼為 0000 0101,負 5 的原碼就是 1000 0101,區別只有符號位

反碼

正數的反碼與其原碼相同

負數的反碼是對其原碼除符號位外,皆取反

例:

使用 12 位(bit)二進制來表示一個數值,那麼正 5 的反碼等同於原碼即為 0000 0000 0101,負 5 的反碼符號位為 1 ,其餘取反即為 1111 1111 1010

補碼

正數的補碼與其原碼相同

負數的補碼是在其反碼的末位加 1去掉最高進位

例:

使用 32 位(bit)二進制來表示,那麼正 5 的補碼等同於原碼即為 0000 0000 0000 0000 0000 0000 0000 0101,負 5 的補碼在反碼末位補 1 去掉最高進位,由於負 5 的反碼加 1 無進位,即為 1111 1111 1111 1111 1111 1111 1111 1011

根據補碼求原碼

上文我們知曉了原碼、反碼、補碼的概念後,應該已經瞭解了由原碼轉換為反碼的過程,但是,若已知一個數的補碼,求原碼的操作呢?

其實,已知補碼求原碼的操作就是對這個補碼再求補碼

如果補碼的符號位為 0,表示是一個正數,那麼它的原碼就是它的補碼

如果補碼的符號位為 1,表示是一個負數,那就直接對這個補碼再求一遍它的的補碼就是它的原碼

例:

求補碼 1001 即十進制 -7 的原碼

我們對補碼再求補碼,也就是先取反再補 1 ,取反得 1110 ,再補一得 1111,我們對照上文中 -7 的原碼,正是 1111

二進制在內存中以補碼存儲

如上述,此時再和大夥説最終結論,二進制數在內存中最終是以補碼的形式存儲的,現在知道為什麼用補碼存儲了嗎,你 GET 到了嗎?

使用補碼,我們可以很方便的將減法運算轉化成加法運算,運算過程得到簡化,正數的補碼即是它所表示的數的真值,而負數的補碼的數值部份卻不是它所表示的數的真值,採用補碼進行運算,所得結果仍為補碼

與原碼、反碼不同,數值 0 的補碼只有一個,4 位為例,即為 0000

再次補充,32 位、12位、8 位和 4 位等的不同就是存儲的值範圍,就像 8 位存儲原碼和反碼的有效值範圍是 -127 ~ +127,補碼範圍是 -128 ~ +127,而 4 位原碼和反碼範圍是 -7 ~ +7,補碼範圍是 -8 ~ +7,這下你大概瞭解到為什麼 JS 會有最大和最小有效數字這個概念了吧

當然我們現在只考慮了整數,並沒有説小數,是為了方便我們理解原碼、反碼和補碼,接着來道

JavaScript中數字存儲

JavaScript 不是類型語言,它與許多其他編程語言不同,JavaScript 沒有不同類型的數字,比如整數、短、長、浮點等等

JavaScript 中,數字不分為整數和浮點型,也就是所有的數字都是使用浮點型類型來存儲,它採用 IEEE 754 標準定義的 64 位浮點格式表示數字,如下圖

  • 第 63 位即 1 位符號位 S (sign)

  • 52 ~ 62位即 11 位階碼 E (exponent bias)

  • 0 ~ 51 位即 52 位尾數 M(Mantissa)

符號位也就是上文説的,表示正負,0 為正,1 為負

符號位我們比較好理解,那麼什麼是尾數什麼又是階碼呢?

什麼是尾數

為了方便解釋,我們直接使用例子,來看十進制數 5.2 的尾數

首先,我們把它整數部分和小數部分依次轉位二進制,不過多重複這個過程,結果如下

101.00110011... // 小數部分 0011 無限循環
複製代碼

一個浮點數的表示方式其實有很多,但規範中一般使用科學計數法,就想上面的 101.00110011... ,我們會使用 1.0100110011.. * 2^2 這種只留一位整數的表達方式,我們稱之為規格化

二進制中只有 0 與 1,按照科學計數法,除了數字 0 ,其餘所有規格化的數字首位只可能是1,對此 IEEE 754 直接省略了這個默認的 1 用來增加存儲值的範圍,所以有效尾數實際上是有 52 + 1 = 53 位的

上文説尾數即表達的是數字的小數部分,也就是説二進制數值 1.0100110011.. * 2^2 的尾數是 0100110011...,因為它是個無限循環小數,所以我們取最大 52 即可,剩餘的就截斷了,這樣就會造成一定的精度損失,這也是為什麼 JS 中 0.1 + 0.2 != 0.3 的原因,如果尾數不足 52 位則在後面補 0 即可

我們可能會疑惑,為什麼除了 0 之外的數字轉二進制後首位都是 1,比如 0.0101 這種 0 < 值 < 1 的二進制小數首位不就是 0 嗎,我們説了是 規格化之後的,二進制小數 0.0101 在規格化之後是 1.01 * 2^-2 ,所以省略首位 1 並不會混淆

什麼是階碼

首先,我們要知道

階碼 = 階碼真值 + 偏移量 1023,偏移量 = 2^(k-1)-1,k 表示階碼位數

階碼真值即為科學記數法中指數真實值的 2 進製表達,它表明了小數點在尾數中的位置

那麼為什麼階碼真值與偏移量相加得到階碼呢?

簡單理解,階碼真值是實際指數中的二進制值,而階碼是指數偏移之後保存起來的二進制數值

還拿上面數值 5.2 來説,它的規格化二進制為 1.0100110011.. * 2^2 ,2 的 2 次方,也就是説它的階碼真值為 2 ,那麼加上偏移量 1023 即 1025,轉二進制後的 11位階碼即為 10000000001

那麼為什麼要偏移呢?

為什麼階碼有偏移量 1023?

此時你可能會比較好奇為什麼階碼會有偏移量這個概念,我們來推導一遍即可

11位的階碼,那麼階碼可以存儲的二進制值範圍為 0~2047,除去 0 與 2047 兩個非規格化情況(非規格化下面會説),變成 1~2046,這裏指的是正數,因為還有負數,那指數範圍就是 -1022~1023,如果沒有偏移量的存在,指數就需引入符號位,因為有負數,還需要引入補碼,無疑會使計算更加複雜,為了簡化操作,才使用無符號的階碼,並引入偏移量的概念

不同情況下的階碼 E

我們上面提到過規格化和非規格化的概念,那麼它們是什麼呢

規格化的情況其實就是上面我們説的一般情況,因為階碼不能為 0 也不能為 2047,所以指數不能為 -1023,也不會為 1024,只有這種情況尾數才會有隱含位 1 即默認忽略的那一位,如下

S + (E!=0 && E!=2047) + 1.M
複製代碼

那麼非規格化就是階碼全為 0,指數為 -1023 的特殊情況了,如果尾數全為 0,則浮點數表示正負 0,否則表示那些非常的接近於 0.0 的數,如下

S + 00000000000 + M
複製代碼

非規格化指的是階碼全為 0 ,那麼表示了還有一種情況階碼全部為 1,指數就是 1024,在這種情況下,如果尾數全部為 0 ,那就是無窮大,若尾數不等於 0,那就是我們常説的 NaN 了

無窮大:S + 111 11111111 + 00000000...

NaN:S + 111 11111111 + (M!=0)
複製代碼

測試一哈

可能大家還是有些迷惑,最好反覆看一看,那麼歇一歇腦子,接下來我們來一個小測試,計算一下十進制數 -15.125 在 JS 內存中的二進制表達方式是多少,動手試一試吧,做完再看答案

都看到這了,動動小手,點個贊吧 😄

如上,求十進制數 -15.125 在 JS 內存中的二進制

首先,由於是負數,那麼符號為就是 1

接着,將 15.125 的整數部分 15 和小數部分 0.125 分別轉位二進制,計算過程不敍述了,整數除 2 取餘逆序排列,小數乘 2 取整順序排列,結果合到一塊為 1111.001

按照科學技術法規格化結果為 1.111001 * 2^3

再接下來,計算階碼,3(階碼真值)+ 1023(偏移量)= 1026

將 1026 轉為 11 位二進制 100 0000 0010 ,即為階碼

尾數即規格化結果數去掉整數 1 的小數部分 1110 01,不足 52 位後補 0 尾數結果為 1110 0100 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

最後,拼接即可

符號位 + 階碼 + 尾數
1 10000000010 1110010000000000000000000000000000000000000000000000
複製代碼

JS中數字範圍

如果大家真的理解了上文,那麼就會發現數字的範圍其實有兩個概念,最大正數和最小負數,最小正數和最大負數

而最終的數字範圍即 最小負數~最大負數 並上 最小正數~最大正數

從S、E、M即數符、階碼、尾數三個維度看,S 代表正負,階碼 E 的值遠大於尾數 M 的個數,所以階碼 E 決定大小,尾數 M 決定精度

So,我們從階碼 E 入手分析

規格化下,當 E 最大值時,2046(最大階碼) - 1023(偏移量) = 1023(階碼真值)即 011 11111111

從階碼 E 的最大值求出的指數(階碼真值)來看,我們可以得到的數值範圍是 -2^1023 ~ 2^1023,使用 JS 的求指函數 Math.pow(2,1023) 得出結果是 8.98846567431158e+307,那麼如果尾數是 1.11111111...,則它就無限接近於 2,我們不算這麼準確,就用 8.98846567431158 x 2 再合上原來的指數,約等於 1.797693134862316e+308

大家還記得我們用 JS 常量 Number.MAX_VALUE 求到的最大數字值嗎,現在就可以在控制枱輸出一下,即 1.7976931348623157e+308,和我們估算出來的值非常相近(因為為了簡單我們把規格化的數字約等於了 2 來計算,算出的數值其實是大了一點的)

所以數字的最大正數和最小負數範圍如下

1.7976931348623157e+308 ~ -1.7976931348623157e+308
複製代碼

如果超過這個值,則數字太大就溢出了,在 JS 中會顯示 Infinity-Infinity,即無窮大與無窮小,學名叫做正向溢出

上面説的是規格化下,那麼非規格化下,也就是指數為 0(最小階碼) - 1023 (偏移量) = - 1023,即 10000000001

從指數來看,我們可以得出最小值是 2^-1023 ,當如果尾數是 0.00000...001

也就是尾數不為 0 的情況,52 位尾數相當於小數點還能虛擬化的向右移動51,可以取得更小的 2^-51 , 所以最小值為為 2^-1074,我們再來計算下 Math.pow(2,-1074) 結果約等於 5e-324

而 JS 最小值常量 Number.MIN_VALUE 得出的值就是是 5e-324

所以數字的最小正數和最大負數範圍即如下

5e-324 ~ -5e-324
複製代碼

如果存了一個數值比可表示的最小數還要小,就顯示成 0,學名反向溢出

JS中整數的範圍

和數字大小不同,數字可以有小數,但是整數就只是單純整數

我們從尾數 M 來分析,精度最多是 53 位(包含規格化的隱含位 1 ),精確整數的範圍其實就是 M 的最大值,即 1.11111111...111 ,也就是 2^53-1 , 使用 JS 函數 Math.pow(2,53)-1 計算得到數字 9007199254740991

所以整數的範圍其實就是

-9007199254740991 ~ 9007199254740991
複製代碼

我們也可以使用 JS 內部常量來獲取下最大與最小安全整數

Number.MIN_SAFE_INTEGER  // -9007199254740991
Number.MAX_SAFE_INTEGER  //  9007199254740991
複製代碼

恰好與我們所求一致

那麼我們説如果整數是這個範圍內,則是安全整數

一個整數是否是安全整數可以使用 JS 的內置方法 Number.isSafeInteger() 來驗證

最後

開發過程中不乏有找過安全範圍的計算,這個時候我們就得要轉為字符串計算了,當然不想自己轉也可以使用開源庫來計算,如 bignumber.jsMath.js 等等

感謝大家的閲讀,此文在之前最開始寫的時候之所以停了就是因為寫着寫着讓二進制搞得有點懵,所以大家一遍如果不太懂可以多看看,不要氣餒,如果此文描述的不太恰當也可以看下文末參考鏈接中的文章輔助理解,如有不正,望指出,謝謝

也歡迎大家關注公眾號「不正經的前端」,來個三連吧,感謝

更多精彩盡在 github.com/isboyjc/blo…

參考文章

原碼、反碼、補碼的產生、應用以及優缺點有哪些?

原碼、反碼、補碼之間的相互關係

[算法]浮點數在內存中的存儲方式

0.1 + 0.2不等於0.3?為什麼JavaScript有這種“騷”操作?

JS中如何理解浮點數?