標籤:
數字、基數及表示整數
整數是這些熟悉的數字 …, -1, 0, +1, +2, …。整數值也被稱作是‘完整的’,並且分為正數(1到無窮大),負數(-1到負無窮大),零(0),非負數(零或正數)和少有的非正數(零或負數)。正數和非負數間的差別通常非常重要,例如C語言典型地用非負數作為數組下標,明確地包括零。
基數
我們書寫整數(和其它數字)時通常使用‘基數10’或‘十進位’算術。這是一種位置符號,每一個‘位置’的值比下一個大十倍。最後一個數字是一的個數,倒數第二個是10的個數,依此類推:因此數字序列593表示‘五個百,九個十和三個一’或者五百九十三。算數上我們有一個基數‘b’(典型地為一個正數,這裡是10)和‘n’個數字序列an-1, an-2, …, a1, a0。這表示數值an-1*bn-1 + an-2*bn-2 + … + a1*b1 + a0*b0。(注意b1 = b及b0 = 1;我們可以簡化這些,但是這種對稱顯得很好。)
注意在這種數學符號中,數字前面允許有多餘的零,但不影響數值:0042有零個千,零個百,四個十和兩個一,這個42是一樣的。通常我們去掉前置字元為零,因為它們不會帶來任何有用的東西。
現代電腦內部使用二進位,或基數2。數字序列100110表示1*25 + 0*24 + 0*23 + 1*22 + 1*21 + 0*20或32 + 4 + 2或38。你可能需要熟悉二進位,至少一些較小的二的冪(1,2,4,8,16,32,64,128,256,等等)。
作為一個C程式員,你還需要熟悉另外兩種基數:8(也成為‘八進位’)和16(‘十六進位’) 大於10的基數有一個符號表示的問題:單個的數字會超過9。C語言使用字母A(大小寫均可)表示10,B表示11,直到F。
在C語言中表示數字四十二,你可以像通常一樣用十進位,或者八進位、十六進位。在八進位中,42需要5個8和兩個1,所以我們寫成52而不是42。在十六進位中,我們需要兩個十六和十個1,所以我們寫成2A。當然如果你寫‘52’,人們可能以為你是指五十二而不是四十二;像表示數字序列一樣,我們需要一些方式清楚表示基數。在數學中我們使用腳標:4210 = 528 = 2A16;一些組合語言使用尾碼而不是腳標;C語言使用首碼。為了表示一個數字是十六進位,我們在其前面置上數字零和一個X(大小寫均可)。為了表示它是八進位,我們在其前面置上數字零。因此在C語言中,42、052和0X2A都表示抽象數‘四十二’。
注意,數字本身沒有改變,因為我們使用不同基數對它進行表示。這個事實是非常重要的:數字本身是一個抽象;不同的標記法是對抽象的具體展示。
二進位(以2為基數)數位一個有用特性是,每位元字總是0或1。零乘任何數字為零,一乘任何其它數字就是那個數,所以只有1‘算數’,只有出現1的位置起作用。2的任何冪包括或不包括。當進位時,我們發現低位元字簡單的來回跳動,高位元字在相鄰的低位從1變為0時才跳進:0000, 0001, 0010, 0011, 0100, 0101, 0110, 0111, 1000, 1001, 1010, …。對於給定的位元字,最大可能的值是它們都是1時(最小值自然是都為0)。而且這個最大值簡單地比2的下一個冪少1:例如,112 = 310,只比1002 = 410少1。如果你記住2的一些冪值,你可以立即說出表示一個給定的數值需要多少個二進位位。例如,因為211是2048及212是4096,一個大於或等於2048但是小於4096的整數,需要11位來表示它。(當然多餘的位也是允許的:在前面的多餘的位需要為0。)
C語言缺乏直接輸出位元的能力,但是八進位和十六進位看起來足夠了。
整數標記法
今天的電腦用二進位儲存數值。它用這種方式易於表示非負整數,直到一個上限。如果一台電腦是8位位元組,並且使用4個位元組儲存一個整數,它有32個有效位。如果我們再去到上面的位置標記法,能馬上看出它能表示數值到231 + 230 + … + 21 + 20,或232-1,或(用十進位)4,294,967,295.
這對於非負整數不錯,但是對於負數呢?隨著時間增長,許多方案被用於二進位電腦中表示負整數。概念上最簡單的一個可能是‘符號-大小’(原碼),也稱‘有符號大小’。其餘的兩個稱為‘一的補碼’(補碼)和‘二的補碼’(反碼)。這三個方法在C語言中都是允許的。在這三種方法中,我們用一位表示符號,用其餘的位表示數值。選擇哪一位不重要,為了符號上的方便,我們傾向選擇‘第一’位(這在後來當我們考慮機器的‘位元組順序’時,導致一些有趣的對抗,因為哪一位是‘第一’,依賴於你查看位和位元組的位置——在低位還是高位一端)。
在符號大小表示中,你簡單地從表示數位所有‘數值’位獲得數位數值,如果符號位有效,稱它為負數。因此數字1(表示符號),011(表示數值)的值是310,並且是負數,表示‘負三’。為了表示正數,我們關閉符號位,得到+3。這個方法的一個缺點是,存在兩個零:常規的或‘表示正數的’零(符號位和數值位都是0),和一個‘負’零(數值位都是0,符號位被置上為1)。一個更嚴重的缺陷是,這種表示在計算時更複雜,拖慢電腦。在我們相加兩個數之前,必須注意它們是否有相同的符號。如果不是,我們必須減去負數,而不僅僅是相加。(當然這給我們一個機會去發現溢出,但就像我們一再從計算中看到的,更重要的是儘快得到一個答案,即使它是錯的。)
一的補碼方法避免了第二種缺陷。在這裡,為了表示一個負數,我們再次置上符號位,但這次我們也反轉所有數值位(把1變為0,反之亦然)。現在為表示負三,我們設定符號位,但讓數值位為空白,給出1(表示符號位),100(表示數值)。當我們相加兩個數,我們需要一個‘迴圈舍入進位’裝置,把進位跳過符號位再加入數值位。
注意觀察在我們相加-3加+2,-3加-1,-3加+4,這是如何工作的(下面的點表示需相加的進位):
1100 (-3) + 0010 (+2) ---- 1110 (-1) . . 1100 (-3) + 1110 (-1) ---- 1011 (-4) . . 1100 (-3) + 0100 (+4) ---- 0001 (+1)
前兩種情況的和是負數,第三種是正數。第一次加法中沒有進位,所以求和很容易。第二次加法中,兩個最大數值位為1產生一個進位,它被加至兩個符號位(兩個開始時已被置上),所以最終的符號位仍被置上。兩個符號位相加時也產生一個進位,並且這個進位被送至末尾,加至最低數值位(在此例中兩個都是0),所以最後的和也置低位為1。最後的例子類似:兩個最大數值位產生一個進位後為0。但這次進位於(一個)符號位相加,得到另一個進位但符號位被設為0。符號位產生的進位被送至末尾,加回最低位。
已看到對零有兩種表示:普通的,通常的零;以及符號位(實際上每一位)被置上的‘負零’。這個值稍較少見,並且在大量使用一的補碼的電腦上,使用‘-0’的企圖會(有時可供選擇)在運行時被捕捉,並因此捕捉使用未被設定‘正確’值的變數的企圖,通過預先用‘全1位’簡單地填充所有記憶體。(一個類似完全以另一種形式的竅門,在今天的電腦上被發現。)
在此,再看幾個使用一的補碼算術的求和例子。注意如果兩方進行相加的低位元值位都是1,它們的和是0(向下一個數值位帶入一個進位),但是一個迴圈舍入進位會導致低位再變為1:
. ... 11001 (-6) + 11011 (-4) ----- 10101 (-10)
(我們必須總共使用五個位——或者在這裡是四次數值位) 類似如果只有一個低位為1,它們的和是1(沒有進位),但是這次一個迴圈舍入進位導致一個進位,發生一連鎖反應。最壞的情況在把‘負零’加到一個值時發生:
..... 00010 (+2) + 11111 (-0) ----- 00010 (+2)
特別地,把-0與+0以外的任何數得到原值,然而-0加+0得到-0。
使用五個位,允許的範圍是-15到+15:
..... 10111 (-8) + 10000 (-15) ----- 01000 (+8)
在此,我們真的得到錯誤的答案,不是因為一的補碼,因為實際上結果放不下了:它應該是-23。
最後一種表示負整數的方法是二的補碼,實際上也是目前為止最常見的。(順便提及,參看Knuth的著作瞭解關於一的補碼和二的補碼的縮寫替代不同的理由。) 這個方法也很像一的補碼,除了符號位被置上時,我們跳過其餘的值並對它們加1,以得到被表示的值。因此當-3在四位一的補碼是1100時,它在二的補碼變成1101。額外的步驟——‘加1’——避免了迴圈舍入加一。現在我們可以相加任何兩個值,簡單地拋棄符號位的進位得到和:
. 11010 (-6) + 11100 (-4) ----- 10110 (-10)
這表示我們能使用與相加不帶正負號的整數同樣的機器指令,來相加有符號整數。這不是使用一的補碼的情況:考慮第一個-6加-4得到-10的例子。迴圈舍入進位產生正確的結果(-10),但是如果兩個五位序列是不帶正負號的整數,它們的值分別是25和27,其和為52,或者用二進位表示為110100。當然這個值需要六位而不是五位,但是我們之前得到低位是10101而不是10100。在我們的第二個例子中,當我們相加-6和-4,它們的無符號相等表示不是25和27,而是26和28,它們的和是54,或用二進位表示為110110。現在我們得到正確的地位(和一個進位,它表示結果太大,不能放在無符號的五位序列)。這種計算方法用來實現C語言的不帶正負號的整數。
在大多數情況下,這些都是相當次要的問題,因為作為使用足夠進階語言的程式員,我們並非不得不關心,機器內部是如何?數字這等如砂礫般細節。在某些地方,這些缺口開始顯現——它們在這些地方,理解這些表示和它們的不足變得至關重要。例如符號-大小和一的補碼都有一個我們需要處理的‘負零’,二的補碼有一個不同的問題:它能表示的負值個數比正數多一個。
在符號-大小中,‘全1’位元模式是-1,但是一個符號位和全0是‘-0’。在一的補碼,全1是‘-0’。在二的補碼,全1也是-1——那麼一個符號位和全0是什麼呢?答案是,它是一個沒有對應正值的負值。如果我們得到這些位元模式1000…00,再加1,我們加1到0111…11結果又是1000…00。在十六位中,符號位是1再跟著全0表示-32768,但是最大的正數是+32767。負數。對-32768取負得到-32768(除非,幾乎不會發生地,機器為你捕獲這個錯誤;再說,通常儘快得到錯誤的答案更重要)。
因此,三種標記法都有缺陷:符號-大小容易理解,但是要求大量額外硬體,並且有一個‘負零’;一的補碼要求較少些額外硬體(迴圈舍入進位),並且類似符號大小,需要單獨的指令表示有符號和無符號數,以及仍然有一個‘負零’;二的補碼有一個沒有對應正值的負數。但是二的補碼不需要額外硬體,能夠非常快,以及能夠使用一套簡化的指令,儘快得到錯誤的答案,所以大多數現代電腦這樣做。
順便提及,注意我談及用位反轉來計算一個負數的正值(及可能加1)。對於上面的數字序列求和規則,我們有一個十分優雅的數學解釋。然而不同於an-1乘2n-1,對二的補碼和一的補碼,這個最重要的‘符號’位,分別簡單地有一個值-(2n-1)或-(2n-1-1)。例如對於一個4位位元,低3位表示4、2和1(所以三位的最大值是7)但是首位表示-8和-7而不是8。因此數字序列1001,用二的補碼錶示-8+1或-7,1000表示-8。這些序列用一的補碼,分別表示-7+1或-6,和-7,並且全1在二的補碼裡是-8+7或-1,現在是-7+7或0(因為符號位,它是-1而不是+0)。
定點數
C語言沒有內建的定點數類型,但可以很容易從整型構造出來。對於一個二進位定點數,我們簡單地指定這個數字中一些位作為‘小數部分’。繼續使用上面的數學符號,小數點前的數字表示2n-1,2n-2,等等,直到20。後續的數字表示2-1,2-2,等等。注意2-1是0.5,2-2是0.25,2-3是0.125,等等——所以我們可以表示4.5,4.25和4.75,但是根據我們能用的位元的多少,我們或不能準確地表示某些值。如果後兩位‘越過小數點’,10000表示4.00,10001表示4.25,10010表示4.50以及10011表示4.75,但是沒有位元模式能表示4.10。我們僅能有效‘預乘’(或按比例縮放)2k,這裡k是小數點後的位元:4.5乘4是18,用二進位表示是10010。4.1乘4是16.4,‘點4’部分不能被表示。
C語言的不帶正負號的整數類型,可以很好用於定點數,理所當然它們是無符號的。如果你需要負的定點數,你可以簡單地使用有符號算術。這在大多數情況下都工作的很好,但注意在你乘或除兩個定點數時會發生的事:它們已經包括比例因素2k,所以兩者的乘積包括一個22k的因子,並且除法會去除這個比例因素。根據結果的使用,你可能需要調整比例。可以使用移位操作常識達到這一點,但是移位操作只對不帶正負號的整數作出定義。如果底層硬體使用一的補碼或符號大小標記法,你通常會得到一個錯誤的答案。甚至對於二的補碼,移位一個負數有時可能產生錯誤的答案。(或者你能用儘可能快地得到錯誤答案的思想來安慰自己。)
浮點數
浮點數產生於令人困惑的多種多樣的實現,至少在更老的硬體。C允許幾乎所有這些硬體。關注所有這些細節,遠遠超出這頁網文的範圍,所以我會有意略過似IBM的‘十六進位浮點數’一類的慘事愁雲,集中關注時至今日在大多數微處理器上都有的IEEE浮點數。
考慮一個典型的十進位的‘科學記號標記法’數字7.2345*1012。為了方便起見,它也常常不用上標寫成7.2345E12。它由兩部分組成,有效數字(亦常稱‘尾數’,儘管嚴格來講在這裡尾數是0.2345,有效數字卻是整個7.2345這部分)和指數——在這裡是12。這種標記法有一些有用的特點,它們中僅有一些適用電腦表示。第一個特點是在這種常規格式中,小數點前只有一位元。我們隨後可以用這個避免在電腦中儲存‘小數點’字元。不適用的一個,或至少不能直接應用的是‘有效數字’的概念:數字7.2345有五個數字(我們沒有計算小數點),所以這個數字被認為是精確到5位。如果這個數字精確到六位,我們要寫成7.23450E12:這個額外的零不影響最終的值。第三個特點,現在也用不上的是,乘法和除法更容易——我們乘和除有效數字,然後簡單地加或減指數。令人好奇的是,加法和減法變得更難了。因為1.4171901E7加3.3E1要求用我們稱作的‘反正常化’(因為小數點不是緊挨著第一個數字)的格式,‘重新縮放’其中一個數字:3.3E1首先變成0.0000033E7,然後我們相加兩個數字得到1.4171934E7。(反正常化另一個運算元也是可能的,或者甚至兩個,在相加並正常化結果。然而在一些極端的指數下,情況會變得棘手。相減兩個數值接近的數字也變得微妙。比如1.99999E5減1.99998E5得到0.00001E5。這不得不被重新正常化為1.00000E0。)
科學記號標記法是有效浮點數十進位格式。在這裡很容易陷入這個圈套:電腦算術常常被輸出為十進位浮點數格式,致使容易相信那些數字一直都是如此格式。但實際上,今天大多數電腦使用一個二進位浮點數格式。
考慮二進位浮點數格式的數字4.5。類似上面提及的定點數4.5是22 + 2-1。用二進位表示,那麼是‘100.1’。現在所有我們要做的是‘正常化’這個數字為(使用‘最新發明的’字元B符號代替E)‘1.001b+2’。換言之,我們現在有1個一,0個二分一,0個四分一,1個八分一(1.125),乘以二的平方或4。對於定點數我們簡單地用4縮放——在這個例子中即是。定點數和浮點數之間的不同是比例因素發生改變。為表示9.0,我們將用8(23)放大:現在我們令1個一,0個二分一,0個四分一,一個8分一乘以九得到1.001b+3。
在科學記號標記法十進位浮點數中,按十的冪進行縮放是微不足道的:我們僅更改指數。類似地在二進位浮點數中,按二的冪縮放也是如此簡單。既然9是4.5的兩倍,我們僅把指數加一。為了得到18我們再遞增指數。每次有效位都保持不變1.001;只有指數發生改變。
十分奇怪的是,二進位浮點數在表示類似十分之一的數字時,是絕對令人感到驚駭的。用十進位時這非常容易:1.0e-1。然而在二進位中,十分之一是1個十六分之一(0.0675)加上1個三十二分之一(0.03125)加上0個六十四分之一(0.015625;這令我們到下一個)加0個1/128(0.0078125;繼續到下一個)加1個1/256加1個1/512加0個1/1024,並繼續,得到1.100110011001100…b-4作為它的二進位表示。序列‘1100’永遠重複,相當類似三分之一的十進位形式3.33333…e-1,具有無窮個‘3’。(實際上很容易得知,任何不以數字5結尾的十進位小數都有這個問題。) 幸運的是整數值,至少對於‘足夠小的’整數,總能用二進位浮點數確切表示。
注意,如果我們總是用‘正常化’格式書寫二進位浮點數,不僅二進位小數點總是第二個字元,而且第一個字元總是一個‘1’。它表示我們不僅能省略用於二進位小數點的儲存空間,也能省略用於第一個數位儲存。所以在電腦內部,數字1.1011b+4(它表示16+8+2+1或27)能恰好表示為位元字序列‘1011’和指數(+4)。因此,在IEEE二進位浮點數裡,儲存被分為兩部分:一部分為尾數(這個術語現在的使用恰如其分,因為首位元字不再儲存)一部分為指數。當然我們還有需要處理的討厭的符號問題:尾數和指數都是有符號的,因為數字可能有這樣的形式-1.1011b-4和+1.1011b+4。在這裡使用的IEEE格式的技巧,略為少見:對於更老的電腦和整數,尾數用符號-大小格式儲存;但是指數用一種‘額外的’格式。在這裡,負數會加上一個偏差,讓它們非負。
在不牽涉更多細節的情況下,這表示浮點數有三個部分(而非四個):一個位用於表示尾數的符號;一些固定位表示尾數本身;其餘的位表示指數,用額外的格式。‘額外’依賴所餘位元。例如,IEEE單精確度是一個32位格式,其中1位表示尾數符號,23個位表示尾數,8位表示指數。因為八個無符號位能表示0到255,指數在邏輯上儲存於‘超出128’格式。因為我未打算解釋的理由,然而真正的額度是127,所以一個為1的指數值表示21-127,即是2-126。像我們將看到的,一個為0的指數被用於另一個用途。
因為我們使用正常化格式,23個用於尾數的位實際上儲存24位有效值,加上隱含的前置‘1’位和二進位小數點。我們的數字總是正-負(根據尾數符號)1.一些數字,b,正-負,一些數字(校正的指數)。或者更容易理解,如果真正的尾數值是m,真正的指數(未校正的)為e,表示的值為1.m*2e-127(如果符號位置上則為負數)。
如果你注意到,或熟悉這個情況,現在你會知道一個問題。如果尾數總是一——點——一些數字,我們如何表示0?最好的情況是,我們使用一個最小的常規指數(未校正的值1,表示2-126)是1.000…000b-126,它大約是1.17549435e-38。因此,我們通過聲稱一些指數為‘特殊情況’從尾端竊取一些值。特別地在這個例子中,一個應該表示b-127的全0位指數,被用於兩種特例。為了(或是錯誤的)對稱,一個應該表示b+128的全1位指數,被用於另兩種。
首兩個特別的情況是‘零’和未正常化(或非常規化)數字:尾數是一個全0位的值和一個0指數表示0.0(或者-0.0,如果設定了符號位),然而一個有同樣非零尾數位的數被視為‘非正常化’。這個數字表示為0.m*2e-126而不是1.m*2e-127(或者你喜歡,把二進位小數點向右移一位,把指數減至-127)。當然這裡的e是零,所以這恰好是0.m*2-126(或者小數點位於第一個m之後m.m*2-127)。最小的非零單精確度數字因此是0.00000000000000000000001b-126(二進位小數點後有22個零,其後是1),這是2-149,或約為1.4e-45。
最後兩個特例是,一個全1位指數表示無窮(如果所有尾數位為零)和‘不是數字’或者‘NaN’(如果某些尾數位被置上)。作為一個有用的技巧,如果所有位被置上,指數在定義上即為全1,尾數明顯至少有一些非零位(實際上全為非零),所以這是一個NaN。設定記憶體為全1以捕獲一些對未初始變數的使用。
注意無窮數是有符號數——正無窮數大於所有其它數,負無窮數小於其餘數——但是儘管NaN有一個符號位,它們卻不被視為有符號數。並非每一個實現總能把這個弄正確。更複雜一點,NaNs被分為‘安靜(Quiet)NaNs’和‘訊號(Signaling)NaNs’,QNaNs用於計算時只會令結果變成另一個QNaN,然而SNaNs被用於在運行時捕獲錯誤。一再地,並非每個實現都把這個做對了。
結果超出最大可表示值的運算,會被轉換為正無窮數:例如1e38乘以1e1會溢出,所以會得到正無窮數。超出最小負數的負數同樣溢出變成負無窮數(所以-1e38*1e1是負無窮數)。
對於一個大小為b+127的指數,單精確度數字最大約為1e38。(確切的數字為340282346638528859811704183484516925440或約為3.4e+38,即是1.m*2e-127的值,當m是全1位,e等於254)。雙精確度數通常使用64位,分為1位符號位,52位尾數和11個指數位(儲存額度為1023,因為210是1024)。更大的指數大約為1e308。擴充精度,如果有的話,在一些系統包括IA-32上使用80位,其它系統上使用128位。尾數和指數都進行擴充,指數通常有15位,其餘位為尾數(不盡相同);最大的數字約是1e4932。英特爾IA-32體繫結構浮點數單元較罕見,它們內部使用80位表示全部數字,在裝載或儲存時轉換為32位或64位。但是有一些控制位以補償額外的有效數字精度,對於額外的指數範圍則沒有。這表示一些會溢出的計算,實際上得出並非無窮數的答案。例如相乘兩個雙精確度數字1e200和1e200會溢出,結果不是1e400。用1e300除這個結果(正無窮數)得到無窮數。在許多英特爾晶片上,導致這個發生的唯一途徑,是把運算再儲存至記憶體,它會減慢運算速度。C代碼是這樣的:
double a = 1.0e200, r; r = a * a; r = r / 1.0e300; printf("%g\n", r);
它應該列印‘無窮數’,但是實際上列印‘1.0e100’,在許多英特爾機器上的C編譯器上。(我們再次看到,得到錯誤答案非常重要——至少是IEEE關心的錯誤——儘可能快的。當然有人會爭論,它明顯是正確的答案。然而C和IEEE的運算規則,指出這個結果應該是無窮的。)
數字、基數及表示