JavaScript 中的數字
Mozilla開發者社區是我學習的重要途徑,有一次逛到這個API看到Polyfill有幾行代碼:
var list = Object(this); var length = list.length >>> 0;
由于非CS的某野生專業出身,我對位運算符的了解比較模糊,大概能明白的只是list.length >>> 0
對list.length
做無符號右移,而返回值是>=0
的整數,但背后的運算過程,就不能說得清楚了。復習了一下相關知識,做個筆記。
今天討論什么?
本文,將嘗試從現代計算機中對數字的存儲和計算討論起,這也注定,雖然題目叫”Numbers in JavaScript”,但是大量篇幅應該集中在編程語言中主要使用的數字處理的方式。萬變不離其宗,懂了原理之后,對掌握各種語言圍繞同樣原理構建的Number也就輕松多了。當然,這其中就包括JavaScript。
先想幾個問題吧:
- JavaScript的數字為什么有
0
和-0
? - JavaScript中的
NaN
為什么互不相等? - JavaScript中的數字真的只有一種類型嗎?
- JavaScript中常被詬病的
0.3 - 0.2 == 0.1
原因是什么? - 數組的最大長度是多少?為什么是這個值?
- 上述問題,只有在JavaScript中有嗎? </ol>
- 指數全0且尾數小數部分全0,則這個數字為±0。(符號位決定正負)
- 指數為2e – 1且尾數的小數部分全0,這個數字是±∞。(符號位決定正負)
- 指數為2e – 1且尾數的小數部分非0,這個數字是NaN。 </ol>
當下,計算機如此普及,我相信,即便非程序員也了解:計算機的世界只有0和1。而一個程序員應該了解:0/1組成的東西叫機器碼,有原碼, 反碼, 補碼等。而一個JS程序員應該了解:JS中的數字是不分類型的,也就是沒有byte/int/float/double等的差異。而一個稍微研究ES規范的JS程序員應該了解:JS的number是IEEE 754標準下64-bits的雙精度數值,而且ES中有ToInteger/ToInt32/ToUint32/ToUint16等Type Conversion
。下面,我們就嘗試著討論一下這些。
從硬件的角度上講,維護兩個狀態是相對容易的,比如一個二極管的導通或者截止,一個電脈沖的高或者低,從而在實現集成電路時候可以更加簡單高效,所以計算機普遍使用0和1來存儲和計算。那么,只有0和1,如何表示1234567890呢?這就涉及到機器碼
和真值
。
機器碼和真值
所謂機器碼
是指,整數在計算機中二進制形式。規則很簡單,機器碼的最高位(左第一位)表示數字的正負,0表示正數,1表示負數,其余位按照進制轉換的規則表示具體數字。
所謂真值
是指,機器碼按照上述轉換規則還原的帶有正負的實際整數。
舉例而言,用8-bits表示一個整數,則十進制的整數+6
可表示為:00000110
;十進制的數字-5
可表示為10000101
。這里說的+6
和-5
便是真值
,而表示它們的二進制數便是機器碼
。再次注意,最高位只用于表示正負,比如10000101
的真值是-5
而非133
,以及我們關于機器碼和真值的討論是基于整數范圍的,浮點數在計算機中的存儲方式與整數有很大差值,將另作討論。
有了機器碼,我們便可以在計算機中使用機器碼存儲和計算真值,那么機器碼在計算機中是如何計算的呢?
原碼、反碼、補碼
機器碼分為多種,主要包括原碼
、反碼
、補碼
、移碼
等,今天我們主要總結一下前三個,而移碼
非常簡單,且多用于比較,不做詳細說明。另外需要補充一點,我們在此區分機器碼的這么多種形式,主要是針對的有符號數,而無符號數,不需要使用最高位來表示正負,也就不需要這么多種編碼方式。
原碼:
最高位表示正負,其它位表示真值的絕對值。其中,最高位為0
表示正數或者0,為1
表示負數。
比如,同樣以8bits長度的數串表示+7
的原碼為0000 0111
,-7
的原碼為10000111
。以后,我們會這樣表示:
[+7] = [00000111]原 [-7] = [10000111]原
很明顯,8-bits的原碼能記錄的范圍為:[-127,+127].
原碼的好處在于,易于理解,相對直觀,方便人腦識別和計算。
對于原碼,人腦使用,可以直接計算出其真值然后可以進行后續操作。但對于計算機,首先,因為最高位用于表示正負,所以不能直接參與運算,需要識別然后做特殊處理;其次,具體計算使用絕對值進行操作,所以兩個操作數正負的異同會影響操作符,比如兩個異號相加實際要做減法操作,甚至異號相減還需要判斷絕對值大小然后決定結果正負。如此,我們計算機的運算器設計將會變得異常復雜。下面,我們將了解如何使用反碼和補碼將符號位參與運算,從而使加減法統一簡單高效地處理,這也是反碼和補碼出現的原因。
反碼:
正數的反碼等于其原碼,而負數的反碼則是對其原碼進行符號位不變,其它位逐一取反
的結果。
比如,同樣以8-bits長度的數串表示+7
,那么有如下:
[+7] = [00000111]原 = [00000111]反 [-7] = [10000111]原 = [11111000]反
同樣,8-bits的反碼能記錄的范圍為:[-127,+127]。
在按位取反之后,我們可以有下面的操作:
2 - 3 = 2 + (-3) = [00000010]原 + [10000011]原 = [00000010]反 + [11111100]反 = [11111110]反 = [10000001]原 = -1
上面,我們將減法通過反碼轉化為了加法,如此,我們的運算將會簡單很多,但是反碼的方式同樣存在一些問題:
3 - 3 = 3 + (-3) = [00000011]原 + [10000011]原 = [00000011]反 + [11111100]反 = [11111111]反 = [10000000]原 = -0
出現了-0
,這個值是沒有意義的。另外,按照反碼加法法則,如果最高位有進位,需要在最低位上+1
,那么會出現:
3 - 2 = 3 + (-2) = [00000011]原 + [10000010]原 = [00000011]反 + [11111101]反 (這里最高位有進位,需要在最低位+1) = [00000001]反 = [00000001]原 = 1
這種情況,又增加了反碼運算的復雜性,影響效率,為解決上面的問題,出現了補碼。
補碼:
正數的反碼等于其原碼,而負數的補碼則是對其反碼進行末位加1
的結果。
比如,再同樣以8-bits長度的數串表示+7
,那么有如下:
[+7] = [00000111]原 = [00000111]反 = [00000111]補 [-7] = [10000111]原 = [11111000]反 = [11111001]補
使用補碼,繼續做之前的操作:
2 - 3 = 2 + (-3) = [00000010]原 + [10000011]原 = [00000010]反 + [11111100]反 = [00000010]補 + [11111101]補 = [11111111]補 = [11111110]反 = [10000001]原 = -1
那么,如果是3-3
呢?
3 - 3 = 3 + (-3) = [00000011]原 + [10000011]原 = [00000011]反 + [11111100]反 = [00000011]補 + [11111101]補 = [00000000]補 = [00000000]原 = 0
是否還需要做額外的加法操作?
3 - 2 = 3 + (-2) = [00000011]原 + [10000010]原 = [00000011]反 + [11111101]反 = [00000011]補 + [11111110]補 = [00000001]補 = [00000001]原 = 1
這樣,我們便可以完美的將減法統一到加法之上,而且不需要繁瑣的正負判斷,進位控制,甚至可以節約一個位置。那么,這個位置,也就是10000000
如何處理呢?按照規定,10000000
用來表示-128
,正數的補碼/反碼/原碼相同,而負數的補碼只是占用了-0
的[10000000]原
和[11111111]反
轉換后得到的[10000000]補
表示-128
,但是這個只是幫助理解,不能反向回推得到-128
的原碼和補碼。
所以,8bits的補碼能記錄的范圍為:[-128,+127]。
至此,我們已經了解了,計算機中主要使用的存儲和計算整數的方式,鑒于現代計算機主要使用補碼方式,自然能很容易理解各種數字類型的表示范圍,比如32bits的int范圍為:[-231,231-1]。這對于我們后面理解一些JavaScript中的極端情況至關重要。
稍加補充:
我們可能會想,原碼很容易接受的,可是反碼和補碼的出現是基于什么樣的邏輯或者數學原理呢?這里,我們可以蜻蜓點水地討論一下,因為這個tread已經超出今天話題有點多了。
常用來說明這個原理的例子是時鐘,時鐘的一周有12個數字,那么,如果我們希望從3
調整到8
該如何操作?可以往前+5
,也可以往后-7
。這里的兩個數字,+5
和-7
存在著的關系:它們同時對數字12求余數得到同樣的結果。嚴格的概念是我們小時候學習的同余
,準確的描述上面的關系是+5
和-7
對模12
同余,+5
和-7
是互補關系,互為補碼
。我們可以看出,在模
的數字范圍之內,我們減去一個數字,恰好等于加上這個數字的補碼然后取余。大致就這么描述一下,詳細的過程是需要嚴謹的科學證明,網上有大量的文獻,在此我們適時收住點到為止,有興趣的同學自行google吧。
IEEE 754標準
作為一個JavaScript程序員,我們只有一個Number
,所以我們從一開始就習慣了:
var num1 = 123; var num2 = 1.23;
但是,你知道JS的number是IEEE 754標準的64-bits的雙精度數值嗎?這是一個什么樣的標準?使用這個標準的64-bits雙精度意味著什么?所以,要掌握JavaScript中的數字,我們首先得了解IEEE 754標準
。下面,我將嘗試說明一下這個標準,為我們最后學習JavaScript中的數字做鋪墊。
標準的基本原理:
我們知道,對于計算機而言,數字沒有小數和整數的差別,也就是計算機中沒有小數點
的存在。通過前文的討論,我們已經找到了很完美的整數存儲計算的方案,但是當涉及到小數,我們很容易發現,現有的方案無法解決我們的需求。然后,計算機科學家們便嘗試了多種方案,主要便是定點數
和浮點數
兩種。
所謂定點數,是指小數點位置固定在數串中間的某個特定位置,點兩側分別為數字的整數和小數部分。比如用8-bits字長的數串,小數點固定在正中間位置,那么11001001
和00110101
分別表示1100.1001和11.0101兩個數字。這種方案簡單直觀易理解,但是存在嚴重的空間浪費,以及容易溢出的問題。
所謂浮點數,是指小數點的位置是不固定的,通過科學計數法(這個應該不需要解釋吧)的方式控制小數點的位置,表示不同的數字。這個表示方案便是IEEE 754標準
使用的方案。IEEE 754標準
是目前使用最廣泛的浮點數運算標準。下面我們將主要討論一下此方案。
現在,讓我們想一下小時候學習的科學計數法,比如-123.456
這個數字,轉換成科學計數法應該是:-1.23456 × 10^2
。這里面已經包含了IEEE 754標準
的主要元素。我們梳理一下:第一個,自然是正負號的問題,需要一個標志;然后,需要一個具體的數字,表示有效數字或者精度,如上例的1.23456
;再然后,需要一個控制小數點位置的數字,如上例的10^2
,回憶一下,我們學習科學計數法的時候,要求前面的數字的絕對值大于1而小于10,也就是小于10^2
中的底數(Base)
,進制固定之后,底數應該是固定的,所以這里起決定作用的是指數,也就是上例中的2
。那么,有了這三個元素,我們便可以很輕松的表示出一個數字,并且靈活的調節小數點位置從而控制數字正負、精度和大小。
上面的要素,轉換成標準語言描述,我們稱表示正負的標志叫符號(Sign)
,表示精度的數字為尾數(Mantissa)
或者有效數字(Significand)
,而控制小數點位置的指數就叫指數(Exponent)
,指數和基數(Base)
共同作用參與計算。下圖取自wikipedia,我們直觀地感受下這三個要素在一個數串中的相對關系(fraction區域即等同于前面說的有效數字區域):
了解最基本的原理后,我們來大致看一下IEEE 754標準
做了什么。
首先做的事情就是規定這三個要素在一個數串中占有的位數,試想一下,如果各個實現的位數不確定,那么我們是不是很難正確的還原出原始數字?IEEE 754標準
規定了四種表示浮點數值的方式:單精確度(32位)、雙精確度(64位)、延伸單精確度(43比特以上,很少使用)與延伸雙精確度(79比特以上,通常以80比特實做)。只有32位模式有強制要求,其他都是選擇性的。而現在主流的語言,多提供了單精度和雙精度的實現,我們在此主要比較一下這兩者,如圖是它們各個部分對應上圖,所使用的位數如下:
補充一點的是,無論是科學計數法還是標準的規定,都要求有效數字(不考慮符號位)必須>=1
&& 1
,所以標準要求省略其最高位,于是精度提高一位。比如,32-bits的單精度有效數字區域只有23位,但是精度卻是24位;64-bits的雙精度,擁有52位的有效數字域卻是54位精度的。
然后,還有一個問題,如果按照先有的約定,是不是無法表示小于1的實數?因為,指數一定>=0
,有效數字一定>1
。于是,IEEE 754標準
提出了一個很重要的指數偏移值
。它是說明指數域(Exponent占用的區域)的編碼值為指數的實際值加上某個固定的值,換言之便是,如果我們根據指數域計算出的指數是N,那么參與計算實際浮點數的指數應該是N-指數偏移值
。根據IEEE 754標準的規定,該固定值為2^(e-1) - 1
,其中的e
為存儲指數的比特的長度。比如,從上圖中我們看到,32-bits的單精度是以8-bits表示一個指數域,那么偏移值應該是2^(8-1) - 1 = 128?1 = 127
。所以,容易得出,單精度浮點數的指數部分實際取值是[-127,128]。比如,某個32-bits單精度的指數為十進制的1
,那么指數域的編碼應該是10000001
,某個32-bits單精度的指數域編碼是00000001
,那么該指數的實際值應該是十進制的-126
。這樣,我們就能通過偏移值
將正指數轉換為負指數,從而使浮點數能逼近0
。浮點數的指數計算跟前面討論的機器碼恰好相反,正數的最高位都是1
,而負數的最高位都是0
。</p>
以上的描述,便是IEEE 754標準
最需要我們了解的原理部分,但是,作為一個廣泛使用的工業標準,規定這些還是遠遠不夠的。
稍加補充:
wikipedia對IEEE 754標準
有如下描述:
這個標準定義了表示浮點數的格式(包括負零-0)與反常值(denormal number)),一些特殊數值(無窮(Inf)與非數值(NaN)),以及這些數值的“浮點數運算符”;它也指明了四種數值舍入規則和五種例外狀況(包括例外發生的時機與處理方式)。
</blockquote>
下面,補充幾個,我認為與本文后續討論相關的或者可以幫助大家理解極端現象的定義:
規約形式的浮點數
:如果浮點數中指數部分的編碼值在0 < exponent < 2^(e-1)
之間,且尾數部分最高有效位(即整數字)是1,那么這個浮點數將被稱為規約形式的浮點數。也就是,嚴格按照我們上文描述編碼的數字。
非規約形式的浮點數
:如果浮點數的指數部分的編碼值是0,尾數為非零,那么這個浮點數將被稱為非規約形式的浮點數。IEEE 754標準規定:非規約形式的浮點數的指數偏移值比規約形式的浮點數的指數偏移值大1.例如,最小的規約形式的單精度浮點數的指數部分編碼值為1,指數的實際值為-126;而非規約的單精度浮點數的指數域編碼值為0,對應的指數實際值也是-126而不是-127。實際上非規約形式的浮點數仍然是有效可以使用的,只是它們的絕對值已經小于所有的規約浮點數的絕對值;即所有的非規約浮點數比規約浮點數更接近0。規約浮點數的尾數大于等于1且小于2,而非規約浮點數的尾數小于1且大于0.
上面的兩個概念,幾乎是直接從wikipedia上扒下來的,非規約形式的浮點數
出現的意義是避免突然式下溢出(abrupt underflow)
,而采用漸進式下溢出
。這已經是上世紀70年代的事情了,差不多是我的年齡的兩倍了。這個是一些非常極端的情況,在此我嘗試最簡單地描述一下非規約形式的浮點數
出現的意義,知道有這么回事便可:下面,以單精度為例,如果沒有非規約形式的浮點數
,那么絕對值最小的兩個相鄰的浮點數之間的差值將是絕對值最小的浮點數的2^23
分之一,大家想一下,絕對值次小的浮點數減去絕對值最小的浮點數的值是多少?
1.00...01 × 2^(-126) - 1.00...00 × 2^(-126) = 0.00..01 × 2^(-126)
= 1 × 2^(-126-23)
= 2^(-149)
很明顯,絕對值最小的規約數無法表達其和次小的規約數的差值,所以很容易導致有若干數字之間的差值下溢,可能會觸發意料之外的后果。而如果采用非規約形式的浮點數
,指數全0,偏移值比規約數偏移值大1(-126比-127大1),尾數小于1,那么非規約數能表達的最小值便是:
0.00..01 × 2^(-126) = 1 × 2^(-126-23)
= 2^(-149)
所以,非規約形式的浮點數
解決了前述的突然式下溢出(abrupt underflow)
而被標準采納。
IEEE 754標準
還規定了三個特殊值:
結合前面的規約數,非規約數以及三個特殊值,可以得到如下總結:

現在,讓我們回憶一下,各種語言中普遍描述的雙精度浮點數的范圍:[-1.7 × 10-308,1.7 × 10308]。打個岔,想象一個有300多位的十進制數字的適用情形,私以為遠超過普通人想象力的邊界。這個范圍為什么是這個范圍呢?我覺得,通過上面的討論,大家應該能清晰,1.7/308這些數字出現的必然原因。
首先,我們應該很容易根據偏移量得出雙精度浮點數的計算公式:

然后,以正數為例,按照上述特殊值
中±∞
和NaN
的約定,指數的最大值應該滿足指數取規約數的指數范圍的最大值,然后小數部分取小數部分的最大值,可以得出這個二進制的數字應該是:
0 11111111110 11..11(52個)
轉換為16進制表示:
0x7fef ffff ffff ffff
那么,根據前述規約數的原理,反編碼便得到十進制的:1.7976931348623157 x 10^308
。類似的道理,Sign
位取反,便是范圍的下限。
到此為止吧,我對IEEE 754標準
也是最近幾天稍加學習,再說多了就誤導大家了。通過這幾天的學習,我感覺,我們在理解的IEEE 754標準
及浮點數的時候,要特別注意將精度和范圍兩個概念分別開來。范圍只是一個模糊的界限,精度才是能準確表達的數字。
回到JavaScript
在上面的討論中,我們很少提及JavaScript,似乎有點背離今天的主題了,但是,在了解了前述的原理之后,我們對JavaScript中數字的把握將”水到渠成”。這終將是一次,鋪墊多于正文,開胃菜多于正餐的討論。嗯,快喊小伙伴,正餐開始了!
ES的”The Number Type”:
現在,我們打開ES規范的“The Number Type”是不是基本通讀下來了? 比如:
The Number type has exactly 18437736874454810627 (that is, 264 ? 253 + 3) values…
</blockquote>
為什么是這個數字?因為,我們說JavaScript中的數字是64-bits的雙精度,所以首先有2^64
中可能的組合,然后,按照前述的IEEE 754標準
的標準中的特殊值
中的部分,NaN
和±∞
占用了2^53
個數值,但是表示了三個直觀的量,所以,加減一下,自然就是18437736874454810627 (that is, 2^64 ? 2^53 + 3) values
。
…the 9007199254740990 (that is, 253?2) distinct “Not-a-Number” values…
</blockquote>
為什么這么多NaN
?同樣,按照前述的IEEE 754標準
的標準中的特殊值
中的部分,NaN
使用了Significand
非零、指數是特定2^e-1
且Sign
無要求的所有可能,即2^53
減去±∞
兩種情況。
…e is an integer ranging from ?1074 to 971…
</blockquote>
為什么指數的范圍是這個呢?而不是-1022
到+1022
呢?因為,ES演化了一下公式,對比一下我們之前演示64-bits的公式,關于參與計算的mantissa
,我們按照IEEE 754標準
在演示的時候中使用的是1.m
,而ES規范中使用的是m
,當然會有尾數域
bit長度的差異了。
到這里,關于數字,大概就可以結束了。開篇的幾個問題,相信讀到這里的同學,都能有答案了。但是,還有一個問題,JavaScript中的數字真的只有一種類型嗎?
,而且貌似到現在與我們的初衷,理解>>>
有點偏離了。不過,世界上很多事情往往都是這樣,解釋原理需要到口干舌燥,而用原理去解釋現象卻只需要三言兩語。
JavaScript不是只有64-bits的雙精度
是的,小標題已經回答了我們的問題,JavaScript不是只有64-bits的雙精度。我們通篇都在說JavaScript中數字的各種,一直按照64-bits的雙精度來描述,但是,如之前所說,ES中有ToInteger/ToInt32/ToUint32/ToUint16等Type Conversion
。這些Type Conversion
不是我們直接調用的API,而是語言引擎在進行某些特定操作的時候,替我們做的。這種“隱形的操作”,只有在一些極端的情況下,會表現出來。現在,我們可以到“ToInt32”/“ToUint32”/“ToInt16”三個地方看一下,稍作比較便能發現,他們的差異很小,只是在特定的步驟中存在差異。比如,ToUint32
和ToUint16
的差異僅僅操作的最后一步存在差異,按順序列出比較一下:
Let int32bit be posInt modulo 232; that is, a finite integer value k of Number type with positive sign and less than 232 in magnitude such that the mathematical difference of posInt and k is mathematically an integer multiple of 232.
Return int32bit.
</blockquote>
vs
Let int16bit be posInt modulo 216; that is, a finite integer value k of Number type with positive sign and less than 216 in magnitude such that the mathematical difference of posInt and k is mathematically an integer multiple of 216.
Return int16bit.
</blockquote>
比較一下,不難發現,僅僅是2^32
和2^16
的差異,而關鍵點恰是modulo
操作的時候,按照我們之前討論的原理,很容易理解這個操作決定了可能出現的最大數。這樣的比較,有一好處,能提高我們閱讀標準的速度,而且加深理解,對掌握標準很有幫助。
總結一下這三個操作的范圍:
ToInt32
的范圍便是其它強類型語言中的[-231, -231 – 1]。
ToUint32
的范圍便是其它強類型語言中的[0, -232 – 1]。
ToUint16
的范圍便是其它強類型語言中的[0, -216 – 1]。
通過搜索,很容易能找到,JavaScript中那些操作中使用了上述相關的操作。其中,ToUint16
僅僅在String.fromCharCode
中有使用,我們不做討論了。ToInt32
有在多個位運算符中使用,比如~
/ <<
/ >>
,以及在parseInt
也有使用。而ToUint32
的使用則出現在了大量的地方,主要分布在,數組相關的操作,位運算的操作兩個區域。
我們就借ToUint32
的這些使用,回到開篇討論的那個地方吧:
首先,來到這里>>>
,看到操作如下:
1.Let lref be the result of evaluating ShiftExpression.
2.Let lval be GetValue(lref).
3.Let rref be the result of evaluating AdditiveExpression.
4.Let rval be GetValue(rref).
5.Let lnum be ToUint32(lval).
6.Let rnum be ToUint32(rval).
7.Let shiftCount be the result of masking out all but the least significant 5 bits of rnum, that is, compute rnum & 0x1F. Return the result of performing a zero-filling right shift of lnum by shiftCount bits. Vacated bits are filled with zero. The result is an unsigned 32-bit integer.
</blockquote>
再看new Array (len)
,有一句:
If the argument len is a Number and ToUint32(len) is equal to len, then the length property of the newly constructed object is set to ToUint32(len). If the argument len is a Number and ToUint32(len) is not equal to len, a RangeError exception is thrown.
</blockquote>
對比不難發現,>>>
的返回值和array.length
的取值范圍,無差異,經過>>>
操作后的數字,一定是一個合法的array.length
。解釋原理總是那么復雜,可是用原理解釋現象總是那么簡單。
來自: 隨心小筑
</code>