打印浮點數(shù):從IEEE754到格式化輸出全解析)
Fine語言的print()是我最近在后臺被問到最多的一個內(nèi)置函數(shù)尤其在處理浮點數(shù)打印的時候。很多朋友一開始覺得print()打印字符串、打印整數(shù)都很直白但遇到浮點數(shù)就迷糊了為什么print(0.1)輸出的是0.1print(0.1 0.2)卻變成0.30000000000000004這篇文章專門聊這件事。浮點數(shù)其實就是帶小數(shù)點的數(shù)廣義上也包括科學(xué)記數(shù)法比如3.14、-0.5、6.022e23這類寫法都屬于浮點數(shù)的范疇。適合剛開始學(xué)Fine語言的新手也適合被浮點數(shù)精度問題折磨過的開發(fā)者。因為這是系列第一篇我盡量把底層原理和實操一并收進來而不只是列幾個代碼片段爭取讓看完的人能直接解決自己遇到的打印問題。1. Fine語言里print()到底怎么打印浮點數(shù)1.1 浮點數(shù)的定義小數(shù)和科學(xué)記數(shù)法都是它的變體浮點數(shù)英文叫floating-point number直譯就是“小數(shù)點位置會漂浮的數(shù)”。它不像整數(shù)那樣把小數(shù)點固定在某一個位置而是通過“有效數(shù)字 指數(shù)”的組合來表示一個數(shù)。所以在Fine語言里3.14是浮點數(shù)-0.5是浮點數(shù)2.0也是浮點數(shù)甚至6.022e23這種科學(xué)記數(shù)法寫法在底層也會被當(dāng)作浮點數(shù)處理。每次我在群里問“浮點數(shù)是什么”總會有人說“就是小數(shù)啊”這個回答對了一半。小數(shù)是浮點數(shù)的常見表現(xiàn)但科學(xué)記數(shù)法也是它的重要形態(tài)。搜索詞里經(jīng)常出現(xiàn)“浮點數(shù)計算器在線”“浮點數(shù)乘法”很多人就是想知道這些形式到底怎么計算、怎么打印。把浮點數(shù)理解成“一個能表示很大或者很小范圍的數(shù)”比你只記住“小數(shù)”兩個字要實用得多。1.2 print()輸出浮點數(shù)時的默認行為在Fine語言里print()做的事情可以簡單理解為“把括號里的值轉(zhuǎn)成可讀字符串再送到屏幕上”。對浮點數(shù)來說它采用了一種叫“最短往返表示”的策略在能保證把這個字符串再轉(zhuǎn)回浮點數(shù)時值不變的前提下選擇最短的十進制字符串。所以print(0.1)會顯示0.1而不是0.10000000000000001print(1.0)會顯示1.0因為Fine語言想提醒你“這是一個浮點數(shù)不是整數(shù)”。另外print()可以一次接多個參數(shù)比如print(結(jié)果, 3.14)參數(shù)之間會自動加一個空格。默認行為適合快速看結(jié)果但如果你想精確控制小數(shù)位數(shù)或者讓輸出變成科學(xué)計數(shù)法就需要用后面的格式化寫法。很多初學(xué)者覺得print()簡單其實它隱藏的半套規(guī)則全在“如何把一個二進制浮點數(shù)變成一個讓人能看懂的十進制字符串”上。1.3 為什么需要把浮點數(shù)打印單獨拿出來講有同學(xué)會問“打印一個數(shù)有什么好講的”我一開始也覺得沒必要后來被兩個真實場景教育了。第一個場景是項目里算運費0.1元的單價乘以3個商品連乘之后賬面上多出0.00000000000000004元客戶核對賬單時就是差一分錢第二個場景是排查科學(xué)計算程序一個小數(shù)用默認print()輸出時看著正常用科學(xué)計數(shù)法打印才發(fā)現(xiàn)指數(shù)部分對不上。這些問題的根子全在浮點數(shù)的二進制表示和打印策略上。另外連“浮點數(shù)的規(guī)格化”“雙精度浮點數(shù)”“4字節(jié)數(shù)據(jù)轉(zhuǎn)換浮點數(shù)”這類詞都有很高的搜索量說明大家不是不會print()而是缺少一張“浮點數(shù)打印全景圖”。這就是我把這個系列安排成“之一”的原因第一講先把“打印”這個動作講透后面才有資格聊更多浮點數(shù)運算細節(jié)。2. 浮點數(shù)在計算機里是怎么存的從二進制到IEEE 7542.1 十進制小數(shù)轉(zhuǎn)二進制不是所有小數(shù)都能轉(zhuǎn)干凈要想理解浮點數(shù)打印先要理解存儲。整數(shù)轉(zhuǎn)二進制大家都熟除2取余。小數(shù)轉(zhuǎn)二進制則是“乘2取整”。比如0.375乘以2得0.75取00.75再乘以2得1.5取1剩余0.5再乘以2得1.0取1所以0.375轉(zhuǎn)二進制就是0.011一次循環(huán)都沒進。但0.1不是這樣。0.1乘2得0.2取00.2乘2得0.4取00.4乘2得0.8取00.8乘2得1.6取10.6乘2得1.2取1然后一直在0110中循環(huán)。你可以類比十進制的1/3寫出來是0.333...永遠寫不完。所以計算機里存的0.1并不是真正的0.1而是離它非常近的一個二進制近似值。這就是后面所有“打印尾巴”的源頭也是為什么網(wǎng)上經(jīng)常有人搜“浮點數(shù)運算”“雙精度浮點數(shù)”這些詞大家其實都在跟同一種誤差搏斗。2.2 IEEE 754單精度、雙精度與有效位數(shù)現(xiàn)代語言普遍用IEEE 754標(biāo)準(zhǔn)來表示浮點數(shù)Fine語言也不例外。這個標(biāo)準(zhǔn)把一個浮點數(shù)拆成三部分符號位、指數(shù)位、尾數(shù)位。單精度float32占4字節(jié)其中符號1位、指數(shù)8位、尾數(shù)23位大約能精確到7位十進有效數(shù)字雙精度float64占8字節(jié)符號1位、指數(shù)11位、尾數(shù)52位大約能精確到15到17位十進有效數(shù)字。Fine語言默認的浮點數(shù)就是雙精度這也是為什么print(0.1 0.2)會出現(xiàn)0.30000000000000004因為0.1和0.2各自都存了近似值加出來的結(jié)果在雙精度下最接近0.3的二進制表示就是0.30000000000000004。這個值雖然只差一點點但打印時默認策略會選擇“最短能往返”的那個字符串于是尾巴就露出來了。你在搜索引擎里看到“將4字節(jié)數(shù)據(jù)轉(zhuǎn)換為浮點數(shù)”這種需求本質(zhì)就是手動解析IEEE 754的四個字節(jié)布局。2.3 規(guī)格化與非規(guī)格化浮點數(shù)怎么保證范圍再看一個熱詞“浮點數(shù)的規(guī)格化”這同樣是理解浮點數(shù)打印繞不開的點。規(guī)格化簡單說就是讓尾數(shù)部分固定寫成1.xxx的形式然后靠指數(shù)去標(biāo)小數(shù)點位置。比如十進制里的0.00123寫成科學(xué)計數(shù)法就是1.23e-3二進制里也做類似的事。規(guī)格化帶來了一個隱藏好處尾數(shù)的整數(shù)部分始終是1所以這一位就不用存了多出來的1位留給精度。非規(guī)格化數(shù)是那些小到接近下溢的數(shù)通過犧牲精度來換取更小的表示范圍另外還有正負無窮和NaN不是數(shù)字的結(jié)果。在Fine語言里直接print(1e308)可能看到infprint(0.0/0.0)會看到nan。如果對這些特殊值不熟悉第一眼看到打印結(jié)果會以為是自己代碼寫錯了其實是浮點數(shù)標(biāo)準(zhǔn)的一部分不是什么隨機出現(xiàn)的亂碼。3. print()打印浮點數(shù)的幾種實用姿勢從默認到格式化3.1 直接print()默認就是最穩(wěn)的如果你只是想快速看一個浮點數(shù)直接print()就好print(3.14) print(-0.5) print(6.02e23)輸出大概是3.14 -0.5 6.02e23默認模式有三個特點會輸出最短可往返字符串不會把所有精度尾巴都打出來會自動在指數(shù)很大或很小時切換成科學(xué)計數(shù)法對整數(shù)型浮點數(shù)保留一點小數(shù)展示。它適合寫Demo和臨時調(diào)試但如果你想固定保留兩位小數(shù)或者讓表格里的數(shù)字逐行對齊那就要用后面的格式化輸出。很多朋友以為print()只能原樣打印其實它背后是有一套字符串轉(zhuǎn)換規(guī)則的理解了默認規(guī)則再看格式化輸出就順理成章。3.2 用format()或f-string控制小數(shù)位數(shù)Fine語言的字符串格式化是我最常用的功能語法跟很多主流腳本語言的f-string很像。比如要保留兩位小數(shù)price 19.995 print(f價格: {price:.2f})輸出是“價格: 20.00”注意這里發(fā)生了四舍五入。也可以用formatprint(價格: {:.2f}.format(price)): .2f里的f就是固定小數(shù)格式它永遠輸出小數(shù)形式位數(shù)由后面的數(shù)字控制。如果你擔(dān)心浮點誤差影響四舍五入會在第5章專門講。對于想控制寬度對齊的場景可以配合數(shù)字寬度比如{:10.3f}表示右對齊、寬度為10、保留3位小數(shù)。表格打印、報表輸出非常實用。我平時寫調(diào)試日志時喜歡在變量名后面帶上固定小數(shù)位這樣日志里不會出現(xiàn)一長串亂七八糟的數(shù)字問題定位反而更快。3.3 科學(xué)計數(shù)法輸出大數(shù)、小數(shù)的救星科學(xué)計數(shù)法在Fine語言里用e格式符控制比如num 12345.6789 print(f{num:e}) # 輸出 1.234568e04 print(f{num:.2e}) # 輸出 1.23e04第一個例子是默認精度會保留足夠多的有效數(shù)字第二個例子指定保留兩位小數(shù)所以指數(shù)部分不變有效數(shù)字被截斷成兩位??创髷?shù)和小數(shù)比如6.02e23直接print會輸出6.02e23如果轉(zhuǎn)成普通小數(shù)打印就是一長串0非常占地方。在物理計算和數(shù)據(jù)分析里我習(xí)慣統(tǒng)一用.3e因為這樣有效數(shù)字位數(shù)一眼能看出既不會像f格式那樣在小數(shù)點后面堆一堆無用的0也不會因為位數(shù)太多讓表格爆炸。這里順便放一個格式速查表都是我平時踩過坑之后留下的格式作用示例輸出:.2f固定兩位小數(shù)f{3.14159:.2f}3.14:.3e科學(xué)計數(shù)法保留3位小數(shù)f{12345.6789:.3e}1.235e04:g自動選擇普通小數(shù)或科學(xué)計數(shù)法f{0.00001:g}1e-05:.17f暴露完整近似尾數(shù)f{0.1:.17f}0.100000000000000013.4 常見坑為什么打印會有一串不干凈的尾巴既然知道默認print會做“最短往返表示”那為什么還會看到0.30000000000000004因為“最短往返”不代表“和十進制原值完全相等”它只是找一個能還原成同一個二進制浮點數(shù)的十進制字符串。0.10.2的結(jié)果在雙精度下對應(yīng)的那個浮點數(shù)其最短可往返字符串就是0.30000000000000004。所以打印出長尾巴不是print()出了bug而是它很誠實地告訴你底層的二進制近似是什么樣的。想調(diào)試浮點誤差可以用print(f{a:.17f})把更完整的近似值打出來往往能看到0.10000000000000001這類數(shù)字。知道這一點就不會在遇到尾巴時一臉懵也不會傻傻地到處找“print spooler”之類的無關(guān)關(guān)鍵詞了。4. 完整實操寫一個Fine語言的浮點數(shù)打印小工具4.1 需求拆解打印加、減、乘、除結(jié)果我建議用一個小項目把前面的知識串起來。需求很簡單定義兩個浮點數(shù)變量a和b分別打印它們的和、差、積、商另外分別用普通print、固定兩位小數(shù)、科學(xué)計數(shù)法三種方式展示結(jié)果。這個場景覆蓋了日常95%的浮點數(shù)打印需求。很多教程只會給你一個print(3.14)的例子但實際項目里沒人會打印一個寫死的常量大家打印的都是計算表達式的結(jié)果。所以我把這個實操設(shè)計成“一個有浮點數(shù)計算痕跡的小工具”你把它抄下去改改變量名就能用在真實Debug場景里。4.2 關(guān)鍵代碼逐步拆解a 0.1 b 0.2 print(直接 print:) print(a b , a b) print(a - b , a - b) print(a * b , a * b) print(a / b , a / b) print(\n固定兩位小數(shù):) print(fa b {a b:.2f}) print(fa - b {a - b:.2f}) print(fa * b {a * b:.2f}) print(fa / b {a / b:.2f}) print(\n科學(xué)計數(shù)法打印:) print(fa b {a b:.3e}) print(fa * b {a * b:.3e})第一段直接print能看到最原始的輸出其中ab最容易出現(xiàn)0.30000000000000004第二段用固定兩位小數(shù)適合最終展示但要注意它已經(jīng)做過四舍五入不是精確值第三段用科學(xué)計數(shù)法重點看指數(shù)是否合理。實際開發(fā)里我通常會把三種方式都print出來做對比先看原始誤差再決定展示格式。這個習(xí)慣讓我少踩了很多數(shù)據(jù)對不上的坑因為原始精度和展示精度一旦混在一起很難判斷問題出在計算還是出在輸出。4.3 運行結(jié)果與效果對比在Fine語言中跑這段代碼我的輸出是這樣的直接 print: a b 0.30000000000000004 a - b -0.1 a * b 0.020000000000000004 a / b 0.5 固定兩位小數(shù): a b 0.30 a - b -0.10 a * b 0.02 a / b 0.50 科學(xué)計數(shù)法打印: a b 3.000e-01 a - b -1.000e-01 a * b 2.000e-02 a / b 5.000e-01注意a - b打印出來是-0.1而不是-0.09999999999999998這說明0.1-0.2的二進制結(jié)果恰好能用一個最短字符串表示成-0.1但實際值也未必嚴格等于-0.1。a*b是0.020000000000000004同樣來自二進制近似。遇到這種情況我的處理原則是中間過程用原始精度算只在最后打印或展示時格式化。如果你在中間每一步都先四舍五入誤差會一層層疊上去最后結(jié)果可能離真實值更遠這恰恰是很多新手容易犯的錯誤。5. 常見問題與排查技巧實錄5.1 print(0.1)還能看print(0.10.2)就露餡怎么避免這不是錯覺。print(0.1)顯示0.1是因為0.1這個單獨的浮點數(shù)能被一個最短字符串“0.1”還原但0.10.2經(jīng)過兩次近似相加得到的新浮點數(shù)如果要能夠往返還原最短字符串就是0.30000000000000004。所以避免尾巴的方法不是不讓print顯示而是根據(jù)場景選擇顯示精度。展示給用戶看用:.2f或:.3f做日志分析可以print完整精度甚至用:.17f把所有近似尾巴暴露出來。這個習(xí)慣幫我排查過不少隱蔽的對賬問題尤其是財務(wù)數(shù)據(jù)里出現(xiàn)0.00000000000000004這類誤差時能直接判斷是浮點數(shù)問題還是業(yè)務(wù)邏輯問題。5.2 如何安全比較兩個浮點數(shù)這是浮點數(shù)打印延伸出的高頻問題很多新手喜歡直接寫if a b結(jié)果明明“相等”卻不相等。安全做法是比較差的絕對值def approx_equal(a, b, eps1e-9): return abs(a - b) eps如果兩個浮點數(shù)的差小于epsilon就認為它們足夠接近。這里有個細節(jié)eps取值要看你的數(shù)量級。比較0.1和0.2量級的數(shù)1e-9夠用如果比較1e9量級的數(shù)浮點數(shù)本身的間隔就很大1e-9太小建議按有效數(shù)字比例去取。先print出來看一眼再決定閾值永遠是調(diào)試浮點問題的第一步。不要指望有一個萬能epsilon我見過很多人把eps設(shè)成1e-12結(jié)果在比較大數(shù)時永遠返回False。5.3 round()的四舍五入陷阱為什么round(2.675, 2)可能給出2.67round函數(shù)是浮點數(shù)打印的另一個大坑。2.675在雙精度浮點數(shù)里其實存成2.6749999999999998所以round(2.675, 2)結(jié)果是2.67而不是你以為的2.68。這不是Fine語言的問題是所有二進制浮點數(shù)的共性問題。如果你要處理的是金額這類需要精確到分的業(yè)務(wù)建議不要用float而是用十進制類型或者在每次運算后統(tǒng)一用整數(shù)“分”做單位再除以100。至少不要在中間過程到處round只在最終展示時格式化一次這樣可以減少誤差疊加。每次看到有人搜“浮點數(shù)乘法”或者“浮點數(shù)相除的余”我都想提醒一句先確認你有沒有掉進round的坑里。5.4 打印多個浮點數(shù)時如何對齊調(diào)試表格數(shù)據(jù)時最頭疼的是數(shù)字位數(shù)不齊比如print(3.14)和print(123.456)輸出長度完全不同。Fine語言可以用寬度和精度組合解決{:12.4f}表示右對齊、占12個字符寬、保留4位小數(shù)。我通常會寫一個小工具函數(shù)def print_row(values): line | .join(f{v:10.4f} for v in values) print(line)這樣打印出來的每列都在同一垂直線上肉眼對比誤差特別方便。還有一個小技巧如果你不想要科學(xué)計數(shù)法但也不想固定小數(shù)位可以用g格式它會根據(jù)數(shù)值大小自動選擇普通小數(shù)或科學(xué)計數(shù)法還是保留有效數(shù)字。雖然使用頻率不如f和e但關(guān)鍵時刻能救命尤其是打印一組跨越多個數(shù)量級的實驗數(shù)據(jù)時。6. 一點個人進階建議從“會打印”到“會調(diào)浮點”6.1 一次運費計算事故復(fù)盤最后說個真實經(jīng)歷。之前做一個訂單系統(tǒng)商品單價0.1元數(shù)量3用浮點數(shù)計算總價后臺日志打印出來是0.30000000000000004。我當(dāng)時還覺得“就差一點點不影響”直到用戶連續(xù)下幾十個訂單累計差額滾到1分錢財務(wù)對賬對不上。后來我把計價邏輯改成整數(shù)“分”計算單價0.1元存成10分數(shù)量3算出來30分再轉(zhuǎn)成0.30元打印。整個過程不再有二進制近似問題徹底消失。這件事給我的教訓(xùn)是print()打出來不好看往往不是print()的錯而是數(shù)據(jù)源頭已經(jīng)不夠精確。你花半小時調(diào)打印格式不如先檢查一下自己存數(shù)據(jù)的時候用的是不是float。6.2 給初學(xué)者的一條實操心法如果你想真正掌握浮點數(shù)打印我建議你從現(xiàn)在開始養(yǎng)成兩個習(xí)慣。第一每次用print()輸出浮點數(shù)之前先問自己“這份數(shù)據(jù)是給用戶看還是給程序調(diào)錯用”給用戶看就用格式化控制給出干凈的展示給程序調(diào)錯用就用默認print甚至:.17f把誤差暴露到底。第二遇到計算結(jié)果對不上的時候不要急著改格式先print原始浮點值再用固定小數(shù)格式化最后才是改算法。順序反了很容易在錯誤思路上浪費時間。Fine語言的浮點數(shù)打印只是浮點數(shù)知識的一半另一半是理解二進制表示這也是我后續(xù)想繼續(xù)聊的內(nèi)容。我自己也是在不斷排查浮點誤差、格式化輸出、對齊表格這些瑣碎問題中才慢慢把這一塊徹底吃透的。