離上篇文過去了半年,連載再開!

台幣最小到元,在我小時候還見過五角的台幣,查了一下維基百科,實際上五角還是有效貨幣,不過實務上沒人用就是了,總之,雖然台灣交易一般算到元,不過其實帶有小數點的金額還發生在不同的角落,例如:

  • B2B 交易,供應商報價有可能有小數點。
  • 營業稅前金額,也有可能有小數點。
  • 別國貨幣,也可能有角、分等,用元為單位的話也會有小數點。
  • 油價,例如每公升 26.6 元。
  • 通話費,例如每秒 0.05 元。
  • 充電費,與通話費類似,例如每小時 10 元,以分計費會是每分鐘 0.166667 元。

比較討厭的是時間計價,因為時間是 60 進位,換算成較細的單位後很容易遇到無窮小數問題。

對於電腦算錢,特別是有小數點的錢,如果不特別處理的話,一定會遇到浮點誤差,對於錢這麼重要的數字,誤差是不被允許的,那什麼是浮點誤差?

浮點誤差

先看實際問題:

>>> 0.1 + 0.2
0.30000000000000004

0.1 加 0.2 竟然不是 0.3,很違背常理,但就這麼發生了。

用最簡單的話說,電腦底層是二進位,而一般帶有小數的十進位數字在轉換為二進位制時會出現無限循環,但電腦容量有限只能截斷,截斷後的二進位再轉回十進位時就會出現誤差。

就像 1/3 以十進位表示是 0.3333… 無窮小數一樣,十進位制 0.1 轉為二進位制會是 0.0001100110011…,再轉回十進位制時就不再是 0.1:

>>> 0.1
0.1

>>> format(0.1, '.17f')
'0.10000000000000001'

上面程式碼中,一般情況下,Python 在顯示值時會幫我們「美化」,0.1 就是 0.1,但實際上,對 Python 而言,真正的值是不是精確的 0.1,而僅是近似值。再看 0.3 的例子:

>>> 0.3
0.3

>>> format(0.3, '.17f')
'0.29999999999999999'

>>> 0.1 + 0.2
0.30000000000000004

>>> 0.1 + 0.2 == 0.3
False

可以看到,對電腦來說 0.3 不是 0.3,0.1 + 0.2 也不是 0.3,是不是很逆天?

由於以上種種,所以江湖上流傳一句話「算錢用浮點,遲早被人扁」。

對於浮點誤差比較簡明的解釋,可以看 Python 的這份文件〈浮點數運算:問題與限制〉。

Floating Point Math 展示了各種語言 0.1 + 0.2 的結果。

解決浮點誤差

第一招——不用小數

解決浮點誤差的第一種思路是用更小的單位算錢,因為十進制二進制轉換誤差只發生在小數點,那不用小數點總可以了吧,我用「分」算錢總可以了吧。

因為浮點誤差在多次運算中都會各自產生並累積,這個方案是在運算與儲存都以「分」為單位,只在最終顯示時才轉為以「元」表示。

但要注意的是,浮點誤差也會發生在資料庫,所以這招也得在儲存時以「分」儲存金額,如果資料庫又給其他應用或報表提供資料,它們也得以「分」計算並以「元」顯示。如果是大應用,有多個資料庫與子應用,很容易會精神錯亂,這邊忘記除以一百,那邊忘記乘以一百,或是欄位 A 是分,欄位 B 是元等狀況。

第二招——Decimal

儘管大部分程式語言都有浮點誤差問題,但它們也都提供了類似的解決方案——decimal,不論是內建或外裝,大多程式語言都有個 decimal 工具,例如 decimal.js 或是 Python 內建的 decimal 模組。

最基礎的 decimal 用法:

>>> from decimal import Decimal

>>> Decimal('3.14')
Decimal('3.14')

這個 3.14 就是 3.14,不會是 3.1401 也不會是 3.1399。

要注意的是,建立 Decimal 物件最好餵字串而不是數字,如果餵數字的話:

>>> Decimal(3.14)
Decimal('3.140000000000000124344978758017532527446746826171875')

Python 會以已經發生浮點誤差的數字建立 Decimal 物件,而這當然與我們的預期不符。

Decimal 物件可以直接與另一個 Decimal 物件或整數做算數運算,但不能和浮點數做運算:

>>> Decimal('3.14') + Decimal('1.618')
Decimal('4.758')

>>> Decimal('2') + 3
Decimal('5')

>>> Decimal('3') + 1.618
# TypeError: unsupported operand type(s) for +: 'decimal.Decimal' and 'float'

Decimal 模組還可以依需求設定小數位數與進退位規則,在程式內我們會盡可能保有全部小數點,採取高精度運算,但最後秀給用戶的值可能只秀到小數點三位,甚至只秀整數,就可以利用 quantize() 方法與 ROUND_HALF_UP 做四捨五入:

>>> from decimal import Decimal, ROUND_HALF_UP

>>> total_cost = Decimal('3.1415927')
>>> total_cost.quantize(exp=Decimal('0.001'), rounding=ROUND_HALF_UP)
Decimal('3.142')

餵給 quantize()exp 參數也是一個 Deciaml 物件,表示我們想要的位數,而第二個參數望文生義就是進退位規則啦,ROUND_HALF_UP 表示四捨五入,另外也有無條件進位、無條件捨去等規則。

前面說到電腦底層是以二進位制處理數字,那 decimal 是怎麼解決浮點誤差的呢?其實它也是用上第一招,全部改為整數就好,不過相對於我們自幹的土炮方式,decimal 設計的更周全,除了可以自定位數、進退位規則外,decimal 還有好多其他特性,並且我們不用自己操煩單位換算的工作,省心省力。

Decimal 唯一的缺點是慢,因為它以整數形態儲存數值,並且還要額外的空間存放小數位數、正負號等資訊,相較於以浮點運算器加速的原生浮點數,decimal 顯然要慢得多了,幸好這種慢在體感上並不明顯,特別是在講究數字精確性的應用中,慢這麼一點點更顯得微不足道。

在儲存方面,各大資料庫也有提供相同的 DECIMAL 或 NUMERIC 型別,它們提供與上述 decimal 相似的特性,所以簡單的說,像錢這樣重要又可能帶小數的數字,不管是在應用層還是資料層,最好都用 decimal 來處理,從此再也沒有出門被人扁的風險。

無窮小數問題

前面提到的浮點誤差,來自十進位制與二進位制轉換時的無限循環,然而除此之外,有一些數字本身就是無窮小數,例如 1/3 = 0.3333…。

以高功率充電樁為例,以時間計價一分鐘 28 元,即便在表面上是以分計費,但在系統實作層面,為了提供不同充電站業者足夠大的彈性,還是可能得精算到秒,以免哪個歐洲客戶想以 30 秒計費,或者要做階梯費率什麼的。

總之,以秒計費的話,一秒 28/60 元,相當於 0.046666… 元,這種本身就是無窮小數的狀況就不能靠 decimal 了,Python 有為此開立特別處方 fractions 模組,與 decimal 狀況類似,JS 也有 fraction.js 可供使用。

Fraction

建立 Fraction 物件就是為給它分子、分母:

>>> from fractions import Fraction

>>> Fraction(1, 3)
Fraction(1, 3)

Fraction(1, 3) 表示分數 1/3。

與 decimal 類似,如果有小數,最好以字串形式餵入,避免浮點誤差:

>>> Fraction(1.1)
Fraction(2476979795053773, 2251799813685248)

>>> Fraction('1.1')
Fraction(11, 10)

在這個例子裡沒餵分母,分母會是 1,就是分數 1.1/1。

要做運算的話,其他運算元最好也是 Fraction 或整數物件,如果其他運算元是浮點數的話,那結果會變成不精確的浮點數:

>>> Fraction(28, 60) * 0.5
0.23333333333333334

>>> Fraction(28, 60) * Fraction('0.5')
Fraction(7, 30)

Fraction 與 decimal 用起來頗相似,也一樣有較慢的問題,但也一樣體感不明顯,是追求數字正確時可接受的慢。

與 decimal 不同的是,資料庫沒有類似 fraction 的型別,可以考慮轉成 decimal 儲存,或是如果需要追求極度精確的話,以字串儲存:

>>> str(Fraction(7, 30))
'7/30'

算錢大概就這樣,祝大家數錢數到手抽筋 💸。