一. 前言:換成 Decimal,為什麼還差一分? #
嗨,我是拍拍君!今天來處理一個很容易吵起來的 bug。
拍拍醬的畫面顯示 0.03,後端算出 0.02。兩邊都說自己用了 Decimal,也都沒有碰 float,那到底誰錯?
答案可能是:數字型別一致,計算契約卻不一致。
逐筆捨入再相加,和全部相加才捨入,本來就不一定相同。小數位數、有效位數、遇到正負半值怎麼處理,也各自是不同的設定。
JSON 實戰已經介紹把輸入解析成 Decimal;這一篇往裡面走,專門把「解析之後怎麼算」變成明確、可重現的規則。
我們會做一個虛構加工費試算器:輸入最多兩位有效小數的非負金額,總額乘上固定比例,最後才捨入。
二. 安裝:標準庫內建,先準備測試環境 #
decimal 不需要 pip 安裝。以下範例以 Python 3.14 執行:
mkdir decimal-lab
cd decimal-lab
uv venv --python 3.14
uv pip install pytest
後面使用 uv run --no-project python,讓指令使用當前目錄的 .venv。
準備兩個檔案:pypy_decimal.py 放計算契約,test_decimal_policy.py 放測試;其他短程式可各自存成獨立腳本執行。
三. 建構時保留的是什麼數字? #
先看一組最小實驗:
from decimal import Decimal
assert Decimal("0.1") + Decimal("0.2") == Decimal("0.3")
assert Decimal(0.1) != Decimal("0.1")
print(Decimal(0.1))
Decimal(0.1) 不是「把人類輸入的 0.1 找回來」,而是忠實轉換已經存在的二進位浮點值。
所以精度敏感的邊界,應接收原始十進位字串、整數最小單位,或已經驗證的 Decimal。
Decimal(str(value)) 也不是時間倒流。如果 value 早已經歷浮點計算,遺失的資訊不會自動復原。
另外,Decimal 可以保存尾端零,但相等比較仍看數值:
from decimal import Decimal
left, right = Decimal("1.20"), Decimal("1.2")
assert left == right
assert left.as_tuple().exponent == -2
assert right.as_tuple().exponent == -1
數值相等、表示尺度相同,是兩個不同的條件。 想驗證輸出兩位小數,不能只寫 assert result == Decimal("1.20")。
四. prec 控制有效位數,不是小數位數
#
官方 decimal 文件把運算環境稱為 context。
這裡最容易誤讀的是 prec:它限制運算結果的有效位數,不是指定小數點後保留幾位。
from decimal import Decimal, localcontext
with localcontext() as ctx:
ctx.prec = 4
value = Decimal("12345.67")
assert str(value) == "12345.67" # 建構不先砍成四位
assert value + Decimal("0") == Decimal("1.235E+4")
assert Decimal("1") / Decimal("7") == Decimal("0.1429")
同一個 prec=4,整數部分很大的值會連個位數都保不住;小於一的值卻可能有多位小數。
localcontext() 在區塊結束後還原外層 context。可重用函式不要在 import 時呼叫 getcontext().prec = ...,把別人的運算政策一起改掉。
而且「最後才 quantize」無法補回中間已因精度不足丟掉的數字。先界定輸入範圍,再選足夠的計算精度。
五. 半值遇到誰?把正負數一起測 #
下面都保留兩位小數:
| 原值 | ROUND_HALF_EVEN |
ROUND_HALF_UP |
|---|---|---|
1.245 |
1.24 |
1.25 |
1.255 |
1.26 |
1.26 |
-1.245 |
-1.24 |
-1.25 |
HALF_EVEN 在剛好一半時選末位為偶數的鄰值;HALF_UP 在剛好一半時往遠離零的方向。
不要把 UP 想成「永遠往正無限大」。ROUND_UP 是遠離零,ROUND_DOWN 是朝零,ROUND_CEILING 才是朝正無限大。
from decimal import Decimal, ROUND_HALF_EVEN, ROUND_HALF_UP
cent = Decimal("0.01")
assert Decimal("1.245").quantize(cent, rounding=ROUND_HALF_EVEN) == Decimal("1.24")
assert Decimal("-1.245").quantize(cent, rounding=ROUND_HALF_UP) == Decimal("-1.25")
本文範例選 HALF_UP,是為了展示一份明確政策,不代表所有應用都該用它。要跟外部系統對帳,先確認雙方規則。
六. quantize(0.05) 不等於捨入到五分倍數
#
quantize() 對齊的是目標的指數,不是目標數值的整數倍。
from decimal import Decimal, ROUND_HALF_UP, localcontext
value = Decimal("1.03")
assert value.quantize(Decimal("0.05")) == Decimal("1.03")
with localcontext() as ctx:
ctx.prec = 28
step = Decimal("0.05")
rounded = (value / step).quantize(Decimal("1"), rounding=ROUND_HALF_UP) * step
assert rounded == Decimal("1.05")
要對齊固定級距,就先換算成「幾個級距」,取整數,再乘回去;這段示範的數值範圍很小,因此指定的精度足夠。
如果級距、數值範圍或運算步驟更複雜,精度預算也必須重新驗證,不能把這條式子當成任意精度保證。
七. 分清 Rounded 與 Inexact
#
有時我們不想自動修正輸入,而是想拒絕真正超過兩位小數的值。
兩個 signal 的差別非常重要:丟掉位數會觸發 Rounded;丟掉非零資訊才會觸發 Inexact。
from decimal import Decimal, Inexact, Rounded, localcontext
with localcontext() as ctx:
ctx.clear_flags()
Decimal("1.2300").quantize(Decimal("0.01"))
assert ctx.flags[Rounded]
assert not ctx.flags[Inexact]
ctx.clear_flags()
Decimal("1.234").quantize(Decimal("0.01"))
assert ctx.flags[Inexact]
Flags 會保留到清除為止,不能把上一次運算留下的旗標當成這次結果。
若把 ctx.traps[Inexact] = True,丟失非零資訊時就直接拋出例外;尾端只是多了零的 1.2300 則仍可接受。
這是「最多兩位有效小數」政策,不是「字串最多只能寫兩位小數」政策。若連多餘零都不准,請另做語法限制。
八. 實作一份有界、可預測的計算契約 #
現在把完整程式存成 pypy_decimal.py:
# file: pypy_decimal.py
import re
from decimal import (
Context, Decimal, DivisionByZero, Inexact,
InvalidOperation, Overflow, ROUND_HALF_UP, localcontext,
)
CENT = Decimal("0.01")
RATE = Decimal("0.10")
POLICY = Context(
prec=32, rounding=ROUND_HALF_UP,
traps=[InvalidOperation, DivisionByZero, Overflow],
)
AMOUNT = re.compile(r"(?:0|[1-9][0-9]{0,11})(?:\.[0-9]{1,4})?")
def parse_amount(raw: str) -> Decimal:
if not isinstance(raw, str):
raise TypeError("amount must be a decimal string")
if len(raw) > 17 or AMOUNT.fullmatch(raw) is None:
raise ValueError("invalid amount syntax or range")
with localcontext(POLICY) as ctx:
ctx.traps[Inexact] = True
try:
value = Decimal(raw).quantize(CENT)
except Inexact as error:
raise ValueError("amount has nonzero digits beyond cents") from error
return value
def quote(amounts: list[str]) -> dict[str, str]:
if not isinstance(amounts, list):
raise TypeError("amounts must be a list")
if not 1 <= len(amounts) <= 1000:
raise ValueError("expected 1..1000 amounts")
with localcontext(POLICY):
subtotal = sum((parse_amount(x) for x in amounts), Decimal("0.00"))
fee = (subtotal * RATE).quantize(CENT)
total = subtotal + fee
return {
"subtotal": format(subtotal, ".2f"),
"fee": format(fee, ".2f"),
"total": format(total, ".2f"),
}
這個入口刻意不接受 float、負值、科學記號、空白、千分位、前導零或非 ASCII 數字。
NaN 與 Infinity 本來可以是 Decimal 值,但不屬於這份金額語法。不是換個型別就能省略領域驗證。
範例最多 12 位整數、4 位輸入小數、1000 筆,解析後一律兩位小數;32 位有效數字足以涵蓋這些加總與固定比例乘法。
我們明確建立 Context,再用 localcontext(POLICY) 複製,因此不會繼承呼叫者意外設下的低精度或 Inexact trap。
POLICY 仍是可變物件,請當成模組內部常數使用;有多種版本政策時,建立不同 context,不要執行中修改共用設定。
九. 同樣是 HALF_UP,放的位置仍會改答案 #
把以下程式另存為 compare_rounding.py:
from decimal import Decimal, localcontext
from pypy_decimal import CENT, POLICY, RATE, quote
amounts = ["0.05", "0.05", "0.05"]
with localcontext(POLICY):
per_line = sum(
((Decimal(x) * RATE).quantize(CENT) for x in amounts),
Decimal("0.00"),
)
assert per_line == Decimal("0.03")
assert quote(amounts) == {"subtotal": "0.15", "fee": "0.02", "total": "0.17"}
逐筆加工費是 0.005 → 0.01,三筆合起來是 0.03;總額加工費是 0.015 → 0.02。
這不是浮點誤差,也不是 Decimal 壞掉。兩段程式計算的政策不同。
本文 quote() 選的是「加總後收一次加工費」。如果產品要求逐筆收費,就必須更改實作、範例和測試,不能只換畫面上的格式。
輸出改成字串是為了保留兩位小數的交換契約,不要最後又 float(total)。序列化策略請接著看前面的 JSON 文章。
十. 測試規則,而不是只測一張正常單據 #
建立 test_decimal_policy.py:
# file: test_decimal_policy.py
from decimal import Decimal, Inexact, ROUND_DOWN, localcontext
import pytest
from pypy_decimal import parse_amount, quote
@pytest.mark.parametrize("raw, expected", [
("0", "0.00"), ("1.2", "1.20"), ("1.2300", "1.23"),
("999999999999.99", "999999999999.99"),
])
def test_valid_input(raw, expected):
result = parse_amount(raw)
assert str(result) == expected
assert result.as_tuple().exponent == -2
@pytest.mark.parametrize("raw", [
"1.234", "NaN", "Infinity", "-0.01", "1e2", " 1.20",
"01.20", "1_000", "1.20", "1000000000000", "1.23456",
])
def test_rejected_input(raw):
with pytest.raises(ValueError):
parse_amount(raw)
@pytest.mark.parametrize("raw", [0.1, 1, True, None])
def test_no_implicit_coercion(raw):
with pytest.raises(TypeError):
parse_amount(raw)
def test_round_once_after_sum():
assert quote(["0.05"] * 3) == {
"subtotal": "0.15", "fee": "0.02", "total": "0.17",
}
def test_caller_context_cannot_change_quote():
with localcontext() as ctx:
ctx.prec = 2
ctx.rounding = ROUND_DOWN
ctx.traps[Inexact] = True
result = quote(["999999999999.99"] * 1000)
assert result["total"] == "1099999999999989.00"
assert ctx.prec == 2 and ctx.rounding == ROUND_DOWN
assert ctx.traps[Inexact]
@pytest.mark.parametrize("amounts", [[], ["0"] * 1001])
def test_batch_bounds(amounts):
with pytest.raises(ValueError):
quote(amounts)
uv run --no-project python -m pytest -q
這組測試同時防守語法、數值範圍、不可偷轉型、尾端零、捨入位置與外部 context 污染。
特別是最大值案例:如果未來有人把 prec 誤改成 2,不會等到大額資料進來才發現。
若之後新增折扣、負數退款或不同幣別,請先補政策案例。不要把「目前測試綠了」解讀成所有商業情境都已涵蓋。
結語:精確型別,需要精確規則配合 #
拍拍君會把 Decimal 程式碼審查拆成五個問題:輸入從哪裡來、允許多大、計算精度多少、在哪裡捨入、半值往哪邊走?
先把這五件事寫成契約,再用邊界案例固定下來。型別負責十進位運算,測試負責提醒大家:原來我們約定的是這個答案。
下次再差一分,先別急著加 epsilon。看看兩邊是不是根本在算不同的規則!