引言
下面的一段簡(jiǎn)單程序 0.3 + 0.6 結(jié)果是什么?
var f1 float64 = 0.3
有人會(huì)天真的認(rèn)為是0.9逛腿,但實(shí)際輸出卻是0.8999999999999999(go 1.13.5)問(wèn)題在于大多數(shù)小數(shù)表示成二進(jìn)制之后是近似且無(wú)限的。
以0.1為例荒辕。它可能是你能想到的最簡(jiǎn)單的十進(jìn)制之一策泣,但是二進(jìn)制看起來(lái)卻非常復(fù)雜:0.0001100110011001100…
其是一串連續(xù)循環(huán)無(wú)限的數(shù)字(涉及到10進(jìn)制轉(zhuǎn)換為2進(jìn)制,暫不介紹)跟束。
結(jié)果的荒誕性告訴我們莺奸,必須深入理解浮點(diǎn)數(shù)在計(jì)算機(jī)中的存儲(chǔ)方式及其性質(zhì),才能正確處理數(shù)字的計(jì)算冀宴。
golang 與其他很多語(yǔ)言(C灭贷、C++、Python…)一樣略贮,使用了IEEE-754標(biāo)準(zhǔn)存儲(chǔ)浮點(diǎn)數(shù)甚疟。
IEEE-754 如何存儲(chǔ)浮點(diǎn)數(shù)
IEEE-754規(guī)范使用特殊的以2為基數(shù)的科學(xué)表示法表示浮點(diǎn)數(shù)仗岖。
32位的單精度浮點(diǎn)數(shù) 與 64位的雙精度浮點(diǎn)數(shù)的差異
符號(hào)位:1 為 負(fù)數(shù), 0 為正數(shù)览妖。
指數(shù)位:存儲(chǔ) 指數(shù)加上偏移量轧拄,偏移量是為了表達(dá)負(fù)數(shù)而設(shè)計(jì)的。
小數(shù)位:存儲(chǔ)系數(shù)的小數(shù)位的準(zhǔn)確或者最接近的值讽膏。
以 數(shù)字 0.085 為例檩电。
小位數(shù)的表達(dá)方式
以0.36 為例:
010 1110 0001 0100 0111 1011 = 0.36 (第一位數(shù)字代表1/2,第二位數(shù)字是1/4…,0.36 是所有位相加)
分解后的計(jì)算步驟為:
go語(yǔ)言顯示浮點(diǎn)數(shù) - 驗(yàn)證之前的理論
接下來(lái)用一個(gè)案例有助于我們理解并驗(yàn)證IEEE-754 浮點(diǎn)數(shù)的表示方式桅打。
math.Float32bits 可以為我們打印出32位數(shù)據(jù)的二進(jìn)制表示是嗜。(注:math.Float64bits可以打印64位數(shù)據(jù)的二進(jìn)制)
下面的go代碼將輸出0.085的浮點(diǎn)數(shù)二進(jìn)制表達(dá),并且為了驗(yàn)證之前理論的正確性挺尾,根據(jù)二進(jìn)制表示反向推導(dǎo)出其所表示的原始十進(jìn)制0.085
var number float32 = 0.085
輸出:表明我們對(duì)于浮點(diǎn)數(shù)的理解正確鹅搪。
Starting Number: 0.085000
經(jīng)典問(wèn)題:如何判斷一個(gè)浮點(diǎn)數(shù)其實(shí)存儲(chǔ)的是整數(shù)
下面是一個(gè)有趣的問(wèn)題,如何判斷一個(gè)浮點(diǎn)數(shù)其實(shí)存儲(chǔ)的是整數(shù)遭铺?
思考10秒鐘…
下面是一段判斷浮點(diǎn)數(shù)是否為整數(shù)的go代碼實(shí)現(xiàn)丽柿,我們接下來(lái)逐行分析函數(shù)。
它可以加深對(duì)于浮點(diǎn)數(shù)的理解
func IsInt(bits uint32, bias int) {
1魂挂、要保證是整數(shù)甫题,一個(gè)重要的條件是必須要指數(shù)位大于127,如果指數(shù)位為127涂召,代表指數(shù)為0. 指數(shù)位大于127坠非,代表指數(shù)大于0, 反之小于0.
下面我們以數(shù)字234523為例子:
Starting Number: 234523.000000
第一步,計(jì)算指數(shù)果正。由于 多減去了23炎码,所以在第一個(gè)判斷中 判斷條件為 exponent < -23
exponent := int(bits >> 23) - bias - 23
第二步,
(bits & ((1 << 23) - 1)) 計(jì)算小數(shù)位秋泳。
coefficient := (bits & ((1 << 23) - 1)) | (1 << 23)
| (1 << 23) 代表 將1加在前方潦闲。
1 + 小數(shù) = 系數(shù)。
bits & ((1 << 23) - 1): 00000000011001010000011011000000
第三步迫皱,計(jì)算intTest 只有當(dāng)指數(shù)的倍數(shù)可以彌補(bǔ)最小的小數(shù)位的時(shí)候歉闰,才是一個(gè)整數(shù)。
如下卓起,指數(shù)是17位和敬,其不能夠彌補(bǔ)最后6位的小數(shù)。即不能彌補(bǔ)1/2^18 的小數(shù)既绩。
由于2^18位之后為0.所以是整數(shù)概龄。
exponent: (144 - 127 - 23) = -6
golang decimal 包詳解
要理解decimal包,首先需要知道兩個(gè)重要的概念饲握,Normal number私杜、denormal (or subnormal) number 以及精度蚕键。
1.概念:Normal number and denormal (or subnormal) number
wiki的解釋是:
In computing, a normal number is a non-zero number in a floating-point representation which is within the balanced range supported by a given floating-point format: it is a floating point number that can be represented without leading zeros in its significand.
什么意思呢?在IEEE-754中指數(shù)位有一個(gè)偏移量衰粹,偏移量是為了表達(dá)負(fù)數(shù)而設(shè)計(jì)的锣光。比如單精度中的0.085,實(shí)際的指數(shù)是 -3铝耻, 存儲(chǔ)到指數(shù)位是123誊爹。
所以表達(dá)的負(fù)數(shù)就是有上限的。這個(gè)上限就是2-126瓢捉。如果比這個(gè)負(fù)數(shù)還要小频丘,例如2-127,這個(gè)時(shí)候應(yīng)該表達(dá)為0.1 * 2 ^ -126. 這時(shí)系數(shù)變?yōu)榱瞬皇?為前導(dǎo)的數(shù),這個(gè)數(shù)就叫做denormal (or subnormal) number泡态。
正常的系數(shù)是以1為前導(dǎo)的數(shù)就叫做Normal number搂漠。
2.概念:精度
精度是一個(gè)非常復(fù)雜的概念,在這里筆者討論的是2進(jìn)制浮點(diǎn)數(shù)的10進(jìn)制精度某弦。
精度為d表示的是在一個(gè)范圍內(nèi)桐汤,如果我們將d位10進(jìn)制(按照科學(xué)計(jì)數(shù)法表達(dá))轉(zhuǎn)換為二進(jìn)制。再將二進(jìn)制轉(zhuǎn)換為d位10進(jìn)制靶壮。數(shù)據(jù)不損失意味著在此范圍內(nèi)是有d精度的怔毛。
精度的原因在于,數(shù)據(jù)在進(jìn)制之間相互轉(zhuǎn)換時(shí)腾降,是不能夠精準(zhǔn)匹配的拣度,而是匹配到一個(gè)最近的數(shù)。如圖所示:
在這里暫時(shí)不深入探討螃壤,而是給出結(jié)論:(注:精度是動(dòng)態(tài)變化的蜡娶,不同的范圍可能有不同的精度。這是由于 2的冪 與 10的冪之間的交錯(cuò)是不同的映穗。)
float32的精度為6-8位,
float64的精度為15-17位
目前使用比較多的精準(zhǔn)操作浮點(diǎn)數(shù)的decimal包是shopspring/decimal幕随。鏈接:https://github.com/shopspring/decimal
decimal包使用math/big包存儲(chǔ)大整數(shù)并進(jìn)行大整數(shù)的計(jì)算蚁滋。
比如對(duì)于字符串 “123.45” 我們可以將其轉(zhuǎn)換為12345這個(gè)大整數(shù),以及-2代表指數(shù)赘淮。參考decimal結(jié)構(gòu)體:
type Decimal struct {
在本文中辕录,筆者不會(huì)探討math/big是如何進(jìn)行大整數(shù)運(yùn)算的,而是探討decimal包一個(gè)非常重要的函數(shù):
NewFromFloat(value float64) Decimal
其主要調(diào)用了下面的函數(shù):
func newFromFloat(val float64, bits uint64, flt *floatInfo) Decimal {
此函數(shù)會(huì)將浮點(diǎn)數(shù)轉(zhuǎn)換為Decimal結(jié)構(gòu)梢卸。
讀者想象一下這個(gè)問(wèn)題:如果存儲(chǔ)到浮點(diǎn)數(shù)中的值(例如0.1)本身就是一個(gè)近似值走诞,為什么decimal包能夠解決計(jì)算的準(zhǔn)確性?
原因在于蛤高,deciimal包可以精準(zhǔn)的將一個(gè)浮點(diǎn)數(shù)轉(zhuǎn)換為10進(jìn)制蚣旱。這就是NewFromFloat為我們做的事情碑幅。
下面我將對(duì)此函數(shù)做逐行分析。
//2-4行判斷浮點(diǎn)數(shù)有效性塞绿,不能為NAN或INF
第5行:剝離出IEEE浮點(diǎn)數(shù)的指數(shù)位
exp := int(bits>>flt.mantbits) & (1<<flt.expbits - 1)
第6行:剝離出浮點(diǎn)數(shù)的系數(shù)的小數(shù)位
mant := bits & (uint64(1)<<flt.mantbits - 1)
第7行:如果是指數(shù)位為0沟涨,代表浮點(diǎn)數(shù)是denormal (or subnormal) number;
默認(rèn)情況下會(huì)在mant之前加上1异吻,因?yàn)閙ant只是系數(shù)的小數(shù)裹赴,在前面加上1后,代表真正的小數(shù)位诀浪。
現(xiàn)在 mant = IEEE浮點(diǎn)數(shù)系數(shù) * 2^53
第13行:加上偏移量棋返,exp現(xiàn)在代表真正的指數(shù)。
第14行:引入了一個(gè)中間結(jié)構(gòu)decimal
type decimal struct {
第15行:調(diào)用d.Assign(mant) , 將mant作為10進(jìn)制數(shù)雷猪,存起來(lái)睛竣。
10進(jìn)制數(shù)的每一位都作為一個(gè)字符存儲(chǔ)到 decimal的byte數(shù)組中
func (a *decimal) Assign(v uint64) {
第16行:調(diào)用shift函數(shù),這個(gè)函數(shù)非常難理解春宣。
func (a *decimal) Shift(k int) {
此函數(shù)的功能是為了獲取此浮點(diǎn)數(shù)代表的10進(jìn)制數(shù)據(jù)的整數(shù)位個(gè)數(shù)以及小數(shù)位個(gè)數(shù)酵颁,此函數(shù)的完整證明附后。(注1)
exp是真實(shí)的指數(shù)月帝,其也是能夠覆蓋小數(shù)部分2進(jìn)制位的個(gè)數(shù)躏惋。(參考前面如何判斷浮點(diǎn)數(shù)是整數(shù))
exp - int(flt.mantbits)代表不能被exp覆蓋的2進(jìn)制位的個(gè)數(shù)
如果exp - int(flt.mantbits) > 0 代表exp能夠完全覆蓋小數(shù)位 因此 浮點(diǎn)數(shù)是一個(gè)非常大的整數(shù),這時(shí)會(huì)調(diào)用leftShift(a, uint(k))嚷辅。否則將調(diào)用rightShift(a, uint(-k)), 常規(guī)rightShift會(huì)調(diào)用得更多簿姨。因此我們來(lái)看看rightShift函數(shù)的實(shí)現(xiàn)。
第5行:此for循環(huán)將計(jì)算浮點(diǎn)數(shù)10進(jìn)制表示的小數(shù)部分的有效位為 r-1 簸搞。
n >> k 是一個(gè)重要的衡量指標(biāo)扁位,代表了小數(shù)部分與整數(shù)部分的分割。此函數(shù)的完整證明附后趁俊。(注1)
第21行:此時(shí)整數(shù)部分所占的有效位數(shù)為a.dp -=(r-1)
第24行:這兩個(gè)循環(huán)做了2件事情:
1域仇、計(jì)算10進(jìn)制表示的有效位數(shù)
2、將10進(jìn)制表示存入bytes數(shù)組中寺擂。例如對(duì)于浮點(diǎn)數(shù)64.125暇务,現(xiàn)在byte數(shù)組存儲(chǔ)的前5位就是64125
func rightShift(a *decimal, k uint) {
繼續(xù)回到newFromFloat函數(shù),第18行怔软,調(diào)用了roundShortest函數(shù)垦细,
此函數(shù)非常關(guān)鍵。其會(huì)將浮點(diǎn)數(shù)轉(zhuǎn)換為離其最近的十進(jìn)制數(shù)挡逼。
這是為什么decimal.NewFromFloat(0.1)能夠精準(zhǔn)表達(dá)0.1的原因括改。
參考上面的精度,此函數(shù)主要考察了2的冪與10的冪之間的交錯(cuò)關(guān)系家坎。四舍五入到最接近的10進(jìn)制值嘱能。
此函數(shù)實(shí)質(zhì)實(shí)現(xiàn)的是Grisu3 算法,有想深入了解的可以去看看論文吝梅。筆者在這里提示幾點(diǎn):
1、2^exp <= d < 10^dp焰檩。
2憔涉、10進(jìn)制數(shù)之間至少相聚10^(dp-nd)
3、2的冪之間的最小間距至少為2^(exp-mantbits)
4析苫、什么時(shí)候d就是最接近2進(jìn)制的10進(jìn)制數(shù)兜叨?
如果10^(dp-nd) > 2^(exp-mantbits),表明 當(dāng)十進(jìn)制下降一個(gè)最小位數(shù)時(shí)衩侥,匹配到的是更小的數(shù)字value - 2^(exp-mantbits)国旷,所以d就是最接近浮點(diǎn)數(shù)的10進(jìn)制數(shù)。
func roundShortest(d *decimal, mant uint64, exp int, flt *floatInfo) {
繼續(xù)回到newFromFloat函數(shù)茫死,第19行 如果精度小于19跪但,是位于int64范圍內(nèi)的,可以使用快速路徑峦萎,否則使用math/big包進(jìn)行賦值操作屡久,效率稍微要慢一些。
第36行爱榔,正常情況幾乎不會(huì)發(fā)生被环。如果setstring在異常的情況下會(huì)調(diào)用NewFromFloatWithExponent 指定精度進(jìn)行四舍五入截?cái)唷?/p>
注一:快速的獲取一個(gè)浮點(diǎn)數(shù)代表的十進(jìn)制
以典型的數(shù)字64.125 為例 , 它可以被浮點(diǎn)數(shù)二進(jìn)制精準(zhǔn)表達(dá)為:
Bit Patterns: 0 | 10000000101 | 0000000010000000000000000000000000000000000000000000
Sign: 0 | Exponent: 1029 (6) | Mantissa: 0.001953
即 64.125 = 1.001953125 * 2^6
注意觀察浮點(diǎn)數(shù)的小數(shù)位在第九位有1, 代表2^-9 即 0.001953125.
我們?cè)诟↑c(diǎn)數(shù)的小數(shù)位前 附上數(shù)字1详幽,10000000010000000000000000000000000000000000000000000 代表其為1 / 2^0 .
此時(shí)我們可以認(rèn)為這個(gè)數(shù)代表的是1.001953125. 那么這樣長(zhǎng)的二進(jìn)制數(shù)變?yōu)?0進(jìn)制又是多少呢:4512395720392704筛欢。
即 1.001953125 = 4512395720392704 * 2^(-52)
所以64.125 = 4512395720392704 * 2^(-52) * 2^6 = 4512395720392704 * 2^(-46)
在這里,有一種重要的等式唇聘。即 (2 ^ -46) 等價(jià)于向左移動(dòng)了46位版姑。并且移動(dòng)后剩下的部分即為64,而舍棄的部分其實(shí)是小數(shù)部分0.125。
這個(gè)等式看似復(fù)雜其實(shí)很好證明迟郎,即第46位其實(shí)代表的是245剥险。其除以246后是一個(gè)小數(shù)。依次類推…
因此對(duì)于數(shù)字 4512395720392704 宪肖, 我們可以用4炒嘲,45,451匈庭,4512 … 依次除以 2 ^ 46. 一直到找到數(shù)451239572039270 其除以2^46不為0。這個(gè)不為0的數(shù)一定為6浑劳。
接著我們保留后46位阱持,其實(shí)是保留了小數(shù)位。
假設(shè) 4512395720392704 / 2^46 = (6 + num)
64.125 =(6 + num) * 10 + C = 60 + 10* num + C
當(dāng)我們將通過(guò)位運(yùn)算保留后46位魔熏,設(shè)為A, 則 A / 2^46 = num
所以 (A * 10 + C) / 2 ^46 =(num * 10 +C) = 4.125
此我們又可以把4提取出來(lái)衷咽。實(shí)在精彩鸽扁。
10進(jìn)制小數(shù)位的提取是一樣的,留給讀者自己探索镶骗。
總結(jié)
1桶现、本文介紹了go語(yǔ)言使用的IEEE-754標(biāo)準(zhǔn)存儲(chǔ)浮點(diǎn)數(shù)的具體存儲(chǔ)方式。
2鼎姊、本文通過(guò)實(shí)際代碼片段和一個(gè)腦筋急轉(zhuǎn)彎幫助讀者理解浮點(diǎn)數(shù)的存儲(chǔ)方式骡和。
3、本文介紹了normal number 以及精度這兩個(gè)重要概念相寇。
4慰于、本文詳細(xì)介紹了shopspring/decimal的實(shí)現(xiàn)方式,即借助了big.int唤衫,以及進(jìn)制的巧妙精準(zhǔn)轉(zhuǎn)換婆赠。
5、shopspring/decimal其實(shí)在精度的巧妙轉(zhuǎn)換方面參考了go源碼ftoa函數(shù)的實(shí)現(xiàn)佳励。讀者可以參考go源碼
6休里、shopspring/decimal目前roundShortest函數(shù)有一個(gè)bug,筆者已經(jīng)提交了pr赃承,此bug已在go源碼中得到了修復(fù)妙黍。
7、big.int計(jì)算存在效率問(wèn)題楣导,如果遇到特殊的快速大量計(jì)算的場(chǎng)景可能不太適合废境。
8、還有一些decimal的實(shí)現(xiàn)筒繁,例如tibd/decimal,代碼實(shí)在不忍淬讀噩凹。
9、浮點(diǎn)數(shù)計(jì)算毡咏,除了要解決進(jìn)制的轉(zhuǎn)換外驮宴,還需要解決重要的溢出問(wèn)題,例如相乘常常要超過(guò)int64的范圍呕缭,這就是為什么shopspring/decimal使用了big.int,而tibd/decimal將數(shù)據(jù)轉(zhuǎn)換為了很多的word(int32)堵泽,導(dǎo)致其計(jì)算非常復(fù)雜。
參考資料
1.Why 0.1 Does Not Exist In Floating-Point
2.Normal number
3.7-bits-are-not-enough-for-2-digit-accuracy
4.Decimal Precision of Binary Floating-Point Numbers
5.Introduction To Numeric Constants In Go